@atlaskit/editor-plugin-limited-mode 13.0.18 → 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.
Files changed (31) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/afm-cc/tsconfig.json +9 -0
  3. package/dist/cjs/limitedModePlugin.js +7 -4
  4. package/dist/cjs/pm-plugins/main.js +201 -18
  5. package/dist/cjs/pm-plugins/plugin-key.js +8 -0
  6. package/dist/cjs/pm-plugins/utils/latch-detector-types.js +5 -0
  7. package/dist/cjs/pm-plugins/utils/latch-detector.js +108 -0
  8. package/dist/cjs/pm-plugins/utils/latch-policy-types.js +1 -0
  9. package/dist/cjs/pm-plugins/utils/latch-policy.js +314 -0
  10. package/dist/es2019/limitedModePlugin.js +4 -3
  11. package/dist/es2019/pm-plugins/main.js +195 -16
  12. package/dist/es2019/pm-plugins/plugin-key.js +2 -0
  13. package/dist/es2019/pm-plugins/utils/latch-detector-types.js +1 -0
  14. package/dist/es2019/pm-plugins/utils/latch-detector.js +92 -0
  15. package/dist/es2019/pm-plugins/utils/latch-policy-types.js +0 -0
  16. package/dist/es2019/pm-plugins/utils/latch-policy.js +277 -0
  17. package/dist/esm/limitedModePlugin.js +6 -3
  18. package/dist/esm/pm-plugins/main.js +199 -16
  19. package/dist/esm/pm-plugins/plugin-key.js +2 -0
  20. package/dist/esm/pm-plugins/utils/latch-detector-types.js +1 -0
  21. package/dist/esm/pm-plugins/utils/latch-detector.js +102 -0
  22. package/dist/esm/pm-plugins/utils/latch-policy-types.js +0 -0
  23. package/dist/esm/pm-plugins/utils/latch-policy.js +307 -0
  24. package/dist/types/limitedModePluginType.d.ts +34 -1
  25. package/dist/types/pm-plugins/main.d.ts +12 -4
  26. package/dist/types/pm-plugins/plugin-key.d.ts +2 -0
  27. package/dist/types/pm-plugins/utils/latch-detector-types.d.ts +30 -0
  28. package/dist/types/pm-plugins/utils/latch-detector.d.ts +15 -0
  29. package/dist/types/pm-plugins/utils/latch-policy-types.d.ts +150 -0
  30. package/dist/types/pm-plugins/utils/latch-policy.d.ts +112 -0
  31. package/package.json +7 -3
@@ -0,0 +1,314 @@
1
+ "use strict";
2
+
3
+ var _interopRequireDefault = require("@babel/runtime/helpers/interopRequireDefault");
4
+ Object.defineProperty(exports, "__esModule", {
5
+ value: true
6
+ });
7
+ exports.LatchPolicy = exports.DEFAULT_LATCH_POLICY_CONFIG = void 0;
8
+ var _classCallCheck2 = _interopRequireDefault(require("@babel/runtime/helpers/classCallCheck"));
9
+ var _createClass2 = _interopRequireDefault(require("@babel/runtime/helpers/createClass"));
10
+ var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty"));
11
+ var _median = require("@atlaskit/editor-common/median");
12
+ var _shouldEnableLimitedMode = require("@atlaskit/editor-common/should-enable-limited-mode");
13
+ 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; }
14
+ 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; }
15
+ /**
16
+ * The shipped policy.
17
+ *
18
+ * These values add up to: nothing counts for the first 10s; then a window qualifies on either 6 of
19
+ * 12 keystrokes slower than 100ms with that window's median also over 100ms, or 3 long tasks over
20
+ * 600ms within 30s corroborated by a slow keystroke in that same 30s. Two qualifying windows at
21
+ * least 30s apart latch limited mode.
22
+ *
23
+ * `freezeTaskMs` matches `DEFAULT_FREEZE_THRESHOLD` in
24
+ * `editor-plugin-base/src/pm-plugins/frozen-editor.ts`, which backs the existing
25
+ * `ACTION.BROWSER_FREEZE` telemetry, so production dashboards can be used to calibrate it.
26
+ * `slowInputMs` is deliberately tighter than that file's `DEFAULT_SLOW_THRESHOLD` of 300 — this
27
+ * needs to notice a degraded experience, not just an unusable one.
28
+ *
29
+ * `requiredConfirmations` and `confirmationGapMs` are the values that matter most — see the comment
30
+ * on the former.
31
+ */
32
+ var DEFAULT_LATCH_POLICY_CONFIG = exports.DEFAULT_LATCH_POLICY_CONFIG = {
33
+ warmUpMs: 10000,
34
+ slowInputMs: 100,
35
+ latencyWindowSize: 12,
36
+ latencySlowSamplesRequired: 6,
37
+ freezeTaskMs: 600,
38
+ freezeTasksRequired: 3,
39
+ freezeWindowMs: 30000,
40
+ requiredConfirmations: 2,
41
+ confirmationGapMs: 30000,
42
+ bulkChangeNodeSize: 100,
43
+ bulkChangeSuppressionMs: 2000,
44
+ docSizeThreshold: 750000,
45
+ nodeCountThreshold: 5000
46
+ };
47
+
48
+ /**
49
+ * Decides whether limited mode should be on.
50
+ *
51
+ * The single authority for that question, covering both reasons:
52
+ *
53
+ * - **The document** — too large, too many nodes, or containing a legacy content macro. Evaluated on
54
+ * load and on document replacement, so it can turn back off (a `replaceDocument` onto a smaller
55
+ * page) without costing a full-document walk per transaction.
56
+ * - **The device** — sustained slow keystrokes or repeated long tasks. **One-way**: once the runtime
57
+ * bar is met the policy stops evaluating, so the editor can never oscillate between modes.
58
+ *
59
+ * `isBreached()` is the combined verdict. Everything tunable is in `config`, so the whole high bar is
60
+ * unit-testable without needing to make a real browser slow, and a caller can substitute a
61
+ * differently configured policy. `latch-detector.ts` owns the browser plumbing that feeds the runtime
62
+ * criteria, and takes a policy instance rather than constructing one.
63
+ */
64
+ var LatchPolicy = exports.LatchPolicy = /*#__PURE__*/function () {
65
+ function LatchPolicy(_ref) {
66
+ var now = _ref.now,
67
+ config = _ref.config;
68
+ (0, _classCallCheck2.default)(this, LatchPolicy);
69
+ /** Public so the detector can read the tunables it needs rather than duplicating them. */
70
+ /** Public so the detector shares one clock with the policy. */
71
+ (0, _defineProperty2.default)(this, "latencySamples", []);
72
+ (0, _defineProperty2.default)(this, "freezeTimes", []);
73
+ (0, _defineProperty2.default)(this, "qualifiedWindows", 0);
74
+ (0, _defineProperty2.default)(this, "suppressedUntil", 0);
75
+ (0, _defineProperty2.default)(this, "latched", false);
76
+ (0, _defineProperty2.default)(this, "documentBreached", false);
77
+ /** Cumulative for the session and never cleared, unlike the evidence buffers. */
78
+ (0, _defineProperty2.default)(this, "totalInputSamples", 0);
79
+ (0, _defineProperty2.default)(this, "totalSlowInputs", 0);
80
+ (0, _defineProperty2.default)(this, "totalFreezes", 0);
81
+ this.now = now;
82
+ this.config = _objectSpread(_objectSpread({}, DEFAULT_LATCH_POLICY_CONFIG), config);
83
+ this.startedAt = now();
84
+ }
85
+
86
+ /**
87
+ * Whether limited mode should be on, for either reason. This is the verdict consumers act on.
88
+ */
89
+ return (0, _createClass2.default)(LatchPolicy, [{
90
+ key: "isBreached",
91
+ value: function isBreached() {
92
+ return this.documentBreached || this.latched;
93
+ }
94
+
95
+ /**
96
+ * What the latch was based on, or `undefined` while un-latched. Intended for telemetry — nothing in
97
+ * the decision reads it back.
98
+ */
99
+ }, {
100
+ key: "getLatchDetails",
101
+ value: function getLatchDetails() {
102
+ return this.latchDetails;
103
+ }
104
+
105
+ /** Whether the runtime (device) criteria have latched. One-way, and never cleared. */
106
+ }, {
107
+ key: "isLatched",
108
+ value: function isLatched() {
109
+ return this.latched;
110
+ }
111
+
112
+ /**
113
+ * Latch the runtime reason directly, without accumulating evidence for it.
114
+ *
115
+ * The policy latches itself when its own criteria are met, so this exists for callers that have
116
+ * already decided: the plugin replaying the detector's latch transaction, and dev tooling forcing
117
+ * the state by hand. Idempotent, and one-way like every other route to `latched`.
118
+ */
119
+ }, {
120
+ key: "latch",
121
+ value: function latch() {
122
+ if (this.latched) {
123
+ return;
124
+ }
125
+ this.latched = true;
126
+ this.latchDetails = this.buildDetails('forced', undefined);
127
+ }
128
+
129
+ /** Whether the document currently breaches the thresholds. Can go back to false. */
130
+ }, {
131
+ key: "isDocumentBreached",
132
+ value: function isDocumentBreached() {
133
+ return this.documentBreached;
134
+ }
135
+
136
+ /**
137
+ * Evaluate the document reason against the size / node-count / legacy-content-macro thresholds.
138
+ *
139
+ * Walks the whole document, so the caller decides when it is worth paying for: `pm-plugins/main.ts`
140
+ * calls this on load and on `replaceDocument` (e.g. live-to-live page navigation) only, never per
141
+ * transaction. Editing therefore cannot turn the document reason on — a page that grows past the
142
+ * thresholds mid-session is only re-judged the next time it loads — but replacement can still turn
143
+ * it back off.
144
+ */
145
+ }, {
146
+ key: "evaluateDocument",
147
+ value: function evaluateDocument(doc) {
148
+ this.documentBreached = (0, _shouldEnableLimitedMode.shouldEnableLimitedModeForDocument)(doc, {
149
+ docSizeThreshold: this.config.docSizeThreshold,
150
+ nodeCountThreshold: this.config.nodeCountThreshold
151
+ });
152
+ }
153
+
154
+ /**
155
+ * Whether a `doc.nodeSize` delta is large enough to be bulk work rather than typing. A keystroke
156
+ * moves this by 1; a paste, a bulk replace or a document load moves it far more.
157
+ */
158
+ }, {
159
+ key: "isBulkChange",
160
+ value: function isBulkChange(nodeSizeDelta) {
161
+ return Math.abs(nodeSizeDelta) >= this.config.bulkChangeNodeSize;
162
+ }
163
+
164
+ /**
165
+ * Discard signals for a window. Called for bulk work, which is expensive but transient and
166
+ * self-limiting, so its cost must not be attributed to the device struggling.
167
+ */
168
+ }, {
169
+ key: "suppress",
170
+ value: function suppress() {
171
+ if (this.latched) {
172
+ return;
173
+ }
174
+ this.suppressedUntil = this.now() + this.config.bulkChangeSuppressionMs;
175
+ }
176
+
177
+ /**
178
+ * Feed one keystroke's input latency (dispatch through to the next animation frame).
179
+ */
180
+ }, {
181
+ key: "recordInputLatency",
182
+ value: function recordInputLatency(durationMs) {
183
+ if (!this.canRecord()) {
184
+ return 'ignored';
185
+ }
186
+ var _this$config = this.config,
187
+ slowInputMs = _this$config.slowInputMs,
188
+ latencyWindowSize = _this$config.latencyWindowSize,
189
+ latencySlowSamplesRequired = _this$config.latencySlowSamplesRequired;
190
+ this.totalInputSamples += 1;
191
+ if (durationMs > slowInputMs) {
192
+ this.totalSlowInputs += 1;
193
+ // Remembered even once the window rolls over, so the freeze criterion below can check that
194
+ // the jank actually coincided with editing.
195
+ this.lastSlowInputAt = this.now();
196
+ }
197
+ this.latencySamples.push(durationMs);
198
+ if (this.latencySamples.length > latencyWindowSize) {
199
+ this.latencySamples.shift();
200
+ }
201
+ if (this.latencySamples.length < latencyWindowSize) {
202
+ return 'recorded';
203
+ }
204
+ var slowSamples = this.latencySamples.filter(function (sample) {
205
+ return sample > slowInputMs;
206
+ }).length;
207
+ if (slowSamples < latencySlowSamplesRequired) {
208
+ return 'recorded';
209
+ }
210
+
211
+ // Median rather than mean: a mean is dragged over the threshold by one or two outliers, which
212
+ // is exactly the transient jank this policy is meant to ignore.
213
+ var windowMedian = (0, _median.median)(this.latencySamples);
214
+ if (windowMedian <= slowInputMs) {
215
+ return 'recorded';
216
+ }
217
+ return this.qualify('inputLatency', windowMedian);
218
+ }
219
+
220
+ /**
221
+ * Feed one `longtask` PerformanceObserver entry.
222
+ */
223
+ }, {
224
+ key: "recordLongTask",
225
+ value: function recordLongTask(durationMs) {
226
+ if (!this.canRecord()) {
227
+ return 'ignored';
228
+ }
229
+ var _this$config2 = this.config,
230
+ freezeTaskMs = _this$config2.freezeTaskMs,
231
+ freezeWindowMs = _this$config2.freezeWindowMs,
232
+ freezeTasksRequired = _this$config2.freezeTasksRequired;
233
+ if (durationMs <= freezeTaskMs) {
234
+ return 'recorded';
235
+ }
236
+ var now = this.now();
237
+ this.totalFreezes += 1;
238
+ this.freezeTimes.push(now);
239
+ this.freezeTimes = this.freezeTimes.filter(function (time) {
240
+ return now - time <= freezeWindowMs;
241
+ });
242
+ if (this.freezeTimes.length < freezeTasksRequired) {
243
+ return 'recorded';
244
+ }
245
+
246
+ // Corroboration. `longtask` is process-wide, so without this a busy background tab or an
247
+ // unrelated app could latch an editor the user is typing in perfectly happily.
248
+ if (this.lastSlowInputAt === undefined || now - this.lastSlowInputAt > freezeWindowMs) {
249
+ return 'recorded';
250
+ }
251
+ return this.qualify('freeze');
252
+ }
253
+ }, {
254
+ key: "canRecord",
255
+ value: function canRecord() {
256
+ if (this.latched) {
257
+ return false;
258
+ }
259
+ var now = this.now();
260
+ if (now - this.startedAt < this.config.warmUpMs) {
261
+ return false;
262
+ }
263
+ return now >= this.suppressedUntil;
264
+ }
265
+
266
+ /** Snapshot of what the latch was based on. Called before the evidence buffers are cleared. */
267
+ }, {
268
+ key: "buildDetails",
269
+ value: function buildDetails(reason, latencyMedianMs) {
270
+ var _this$firstWindowReas;
271
+ var now = this.now();
272
+ return {
273
+ reason: reason,
274
+ firstWindowReason: (_this$firstWindowReas = this.firstWindowReason) !== null && _this$firstWindowReas !== void 0 ? _this$firstWindowReas : reason,
275
+ requiredConfirmations: this.config.requiredConfirmations,
276
+ documentAlreadyBreached: this.documentBreached,
277
+ msFromFirstWindow: this.firstQualifiedAt === undefined ? undefined : Math.round(now - this.firstQualifiedAt),
278
+ latencyMedianMs: latencyMedianMs === undefined ? undefined : Math.round(latencyMedianMs),
279
+ timeToLatchMs: Math.round(now - this.startedAt),
280
+ totalInputSamples: this.totalInputSamples,
281
+ totalSlowInputs: this.totalSlowInputs,
282
+ totalFreezes: this.totalFreezes
283
+ };
284
+ }
285
+ }, {
286
+ key: "qualify",
287
+ value: function qualify(reason, latencyMedianMs) {
288
+ var now = this.now();
289
+
290
+ // Each qualifying window must be independent evidence, so the buffers are cleared rather than
291
+ // left to re-trigger off the same samples on the very next keystroke.
292
+ this.latencySamples = [];
293
+ this.freezeTimes = [];
294
+
295
+ // Too soon after the last counted window to be independent of it, so it earns no credit. The
296
+ // buffers above are still cleared, which is what makes the run rebuild from scratch.
297
+ if (this.lastQualifiedAt !== undefined && now - this.lastQualifiedAt < this.config.confirmationGapMs) {
298
+ return 'qualified';
299
+ }
300
+ this.qualifiedWindows += 1;
301
+ this.lastQualifiedAt = now;
302
+ if (this.firstQualifiedAt === undefined) {
303
+ this.firstQualifiedAt = now;
304
+ this.firstWindowReason = reason;
305
+ }
306
+ if (this.qualifiedWindows < this.config.requiredConfirmations) {
307
+ return 'qualified';
308
+ }
309
+ this.latched = true;
310
+ this.latchDetails = this.buildDetails(reason, latencyMedianMs);
311
+ return 'latched';
312
+ }
313
+ }]);
314
+ }();
@@ -1,7 +1,8 @@
1
1
  import { getNodeIdProvider } from '@atlaskit/editor-common/node-anchor';
2
2
  import { usePluginStateEffect } from '@atlaskit/editor-common/use-plugin-state-effect';
3
3
  import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
4
- import { createPlugin, limitedModePluginKey } from './pm-plugins/main';
4
+ import { limitedModePluginKey } from './pm-plugins/plugin-key';
5
+ import { createPlugin } from './pm-plugins/main';
5
6
  export const limitedModePlugin = ({
6
7
  api
7
8
  }) => {
@@ -10,7 +11,7 @@ export const limitedModePlugin = ({
10
11
  pmPlugins() {
11
12
  return [{
12
13
  name: 'limitedModePlugin',
13
- plugin: createPlugin
14
+ plugin: () => createPlugin(api)
14
15
  }];
15
16
  },
16
17
  getSharedState(editorState) {
@@ -18,7 +19,7 @@ export const limitedModePlugin = ({
18
19
  return {
19
20
  get enabled() {
20
21
  var _limitedModePluginKey, _limitedModePluginKey2;
21
- return (_limitedModePluginKey = (_limitedModePluginKey2 = limitedModePluginKey.getState(editorState)) === null || _limitedModePluginKey2 === void 0 ? void 0 : _limitedModePluginKey2.documentSizeBreachesThreshold) !== null && _limitedModePluginKey !== void 0 ? _limitedModePluginKey : false;
22
+ return (_limitedModePluginKey = (_limitedModePluginKey2 = limitedModePluginKey.getState(editorState)) === null || _limitedModePluginKey2 === void 0 ? void 0 : _limitedModePluginKey2.enabled) !== null && _limitedModePluginKey !== void 0 ? _limitedModePluginKey : false;
22
23
  },
23
24
  limitedModePluginKey
24
25
  };
@@ -1,29 +1,208 @@
1
+ import { ACTION, ACTION_SUBJECT, EVENT_TYPE } from '@atlaskit/editor-common/analytics';
1
2
  import { SafePlugin } from '@atlaskit/editor-common/safe-plugin';
2
3
  import { shouldEnableLimitedModeForDocument } from '@atlaskit/editor-common/should-enable-limited-mode';
3
- import { PluginKey } from '@atlaskit/editor-prosemirror/state';
4
- export const limitedModePluginKey = new PluginKey('limitedModePlugin');
5
- export const createPlugin = () => {
4
+ import { isExperimentEnabled } from '@atlaskit/platform-feature-experiments/is-experiment-enabled';
5
+ import { expVal } from '@atlaskit/platform-feature-experiments/exp-val';
6
+ import { limitedModePluginKey } from './plugin-key';
7
+ import { createLatchDetector } from './utils/latch-detector';
8
+ import { LatchPolicy } from './utils/latch-policy';
9
+
10
+ /**
11
+ * Meta shape used to latch limited mode at runtime. Dispatching this is the entire delivery
12
+ * mechanism: the transaction flows through `SharedStateAPI.notifyListeners`, which diffs the plugin's
13
+ * shared state and notifies every consumer. No plugin re-registration and no schema rebuild.
14
+ */
15
+
16
+ /**
17
+ * Hardware hints, reported with a latch so it can be correlated with device class.
18
+ *
19
+ * Telemetry only — the policy no longer takes the hardware into account when deciding, so this
20
+ * exists to answer "which devices are latching" from the data rather than by assumption. Both hints
21
+ * are optional (`deviceMemory` is Chromium-only) and are simply absent where unsupported.
22
+ */
23
+ const getDeviceHints = () => {
24
+ if (typeof navigator === 'undefined') {
25
+ return {};
26
+ }
27
+ const {
28
+ hardwareConcurrency,
29
+ deviceMemory
30
+ } = navigator;
31
+ return {
32
+ hardwareConcurrency,
33
+ deviceMemoryGb: deviceMemory
34
+ };
35
+ };
36
+
37
+ /** Control-arm state: the document decision alone, exactly as before the experiment. */
38
+ const documentOnlyState = doc => {
39
+ const documentSizeBreachesThreshold = shouldEnableLimitedModeForDocument(doc);
40
+ return {
41
+ documentSizeBreachesThreshold,
42
+ latchPolicyBreached: false,
43
+ enabled: documentSizeBreachesThreshold
44
+ };
45
+ };
46
+
47
+ /** Treatment-arm state: whatever the policy says, for both of its reasons. */
48
+ const policyState = policy => ({
49
+ documentSizeBreachesThreshold: policy.isDocumentBreached(),
50
+ latchPolicyBreached: policy.isBreached(),
51
+ enabled: policy.isBreached()
52
+ });
53
+ export const createPlugin = (api, injectedPolicy) => {
54
+ let detector;
55
+ let policy;
56
+ if (injectedPolicy) {
57
+ policy = injectedPolicy;
58
+ } else if (isExperimentEnabled('platform_editor_dynamic_limited_mode')) {
59
+ const config = expVal('platform_editor_dynamic_limited_mode', 'policyConfig', {
60
+ warmUpMs: 10_000,
61
+ slowInputMs: 100,
62
+ latencyWindowSize: 12,
63
+ latencySlowSamplesRequired: 6,
64
+ freezeTaskMs: 600,
65
+ freezeTasksRequired: 3,
66
+ freezeWindowMs: 30_000,
67
+ requiredConfirmations: 2,
68
+ confirmationGapMs: 30_000,
69
+ bulkChangeNodeSize: 100,
70
+ bulkChangeSuppressionMs: 2_000,
71
+ docSizeThreshold: 750_000,
72
+ nodeCountThreshold: 5_000
73
+ });
74
+
75
+ // Resolved once per editor rather than per transaction. When off, no policy is built and the
76
+ // document decision runs inline exactly as it did before this experiment.
77
+ policy = new LatchPolicy({
78
+ now: () => performance.now(),
79
+ config
80
+ });
81
+ }
6
82
  return new SafePlugin({
7
83
  key: limitedModePluginKey,
8
- view: _view => {
9
- return {};
84
+ props: {
85
+ handleTextInput() {
86
+ var _detector;
87
+ (_detector = detector) === null || _detector === void 0 ? void 0 : _detector.measureInput();
88
+
89
+ // Never handle the input — this is measurement only.
90
+ return false;
91
+ }
92
+ },
93
+ view: editorView => {
94
+ // No policy means the experiment is off: no observers, no per-keystroke measurement.
95
+ if (!policy) {
96
+ return {};
97
+ }
98
+ const startedAt = performance.now();
99
+ detector = createLatchDetector({
100
+ policy,
101
+ onLatchCriteriaMet: details => {
102
+ var _api$analytics;
103
+ // Treatment arm only — the detector does not exist in control. See LimitedModeLatchedAEP.
104
+ //
105
+ // `details` is the policy's own snapshot of what it latched on, taken before it cleared
106
+ // its evidence buffers, so it reports the closing window rather than an empty one.
107
+ api === null || api === void 0 ? void 0 : (_api$analytics = api.analytics) === null || _api$analytics === void 0 ? void 0 : _api$analytics.actions.fireAnalyticsEvent({
108
+ action: ACTION.LIMITED_MODE_LATCHED,
109
+ actionSubject: ACTION_SUBJECT.EDITOR,
110
+ eventType: EVENT_TYPE.OPERATIONAL,
111
+ attributes: {
112
+ latched: true,
113
+ reason: details.reason,
114
+ firstWindowReason: details.firstWindowReason,
115
+ requiredConfirmations: details.requiredConfirmations,
116
+ documentAlreadyBreached: details.documentAlreadyBreached,
117
+ msFromFirstWindow: details.msFromFirstWindow,
118
+ latencyMedianMs: details.latencyMedianMs,
119
+ totalInputSamples: details.totalInputSamples,
120
+ totalSlowInputs: details.totalSlowInputs,
121
+ totalFreezes: details.totalFreezes,
122
+ nodeSize: editorView.state.doc.nodeSize,
123
+ // Read at latch time rather than at construction: the hints are static, and this
124
+ // keeps every device fact in one place next to the decision it is compared against.
125
+ ...getDeviceHints(),
126
+ // Measured from the plugin view starting, which is a little later than the policy's
127
+ // own `timeToLatchMs` (it starts at plugin construction).
128
+ timeToLatch: performance.now() - startedAt
129
+ }
130
+ });
131
+
132
+ // The policy already holds the latch; this transaction only prompts the plugin to
133
+ // re-read it, which is what notifies every consumer through shared state.
134
+ editorView.dispatch(editorView.state.tr.setMeta(limitedModePluginKey, {
135
+ latchPolicyBreached: true
136
+ }));
137
+ }
138
+ });
139
+ return {
140
+ destroy: () => {
141
+ var _detector2;
142
+ (_detector2 = detector) === null || _detector2 === void 0 ? void 0 : _detector2.destroy();
143
+ detector = undefined;
144
+ }
145
+ };
10
146
  },
11
147
  state: {
12
148
  init(_config, editorState) {
13
- return {
14
- documentSizeBreachesThreshold: shouldEnableLimitedModeForDocument(editorState.doc)
15
- };
149
+ if (!policy) {
150
+ return documentOnlyState(editorState.doc);
151
+ }
152
+ policy.evaluateDocument(editorState.doc);
153
+ return policyState(policy);
16
154
  },
17
- apply: (tr, currentPluginState, _oldState, _newState) => {
18
- // Don't check the document size if we're already in limited mode.
19
- // We ALWAYS want to re-check the document size if we're replacing the document (e.g. live-to-live page navigation).
155
+ apply: (tr, currentPluginState, oldState, _newState) => {
156
+ var _tr$getMeta;
157
+ const documentReplaced = Boolean(tr.getMeta('replaceDocument'));
158
+ if (!policy) {
159
+ // Control arm, unchanged: skip the traversal once already breached, but always re-check
160
+ // when the document is replaced (e.g. live-to-live page navigation).
161
+ if (currentPluginState.documentSizeBreachesThreshold && !documentReplaced) {
162
+ return currentPluginState;
163
+ }
164
+ return documentOnlyState(tr.doc);
165
+ }
20
166
 
21
- if (currentPluginState.documentSizeBreachesThreshold && !tr.getMeta('replaceDocument')) {
22
- return currentPluginState;
167
+ // The detector's latch arrives as a transaction so that plugin state stays a function of
168
+ // the transaction stream rather than of when `apply` happens to read the policy. Dev
169
+ // tooling dispatches the same meta to force limited mode by hand.
170
+ if ((_tr$getMeta = tr.getMeta(limitedModePluginKey)) !== null && _tr$getMeta !== void 0 && _tr$getMeta.latchPolicyBreached) {
171
+ policy.latch();
23
172
  }
24
- return {
25
- documentSizeBreachesThreshold: shouldEnableLimitedModeForDocument(tr.doc)
26
- };
173
+
174
+ // Only on replacement, never on an ordinary edit: the check walks the whole document, so
175
+ // running it per transaction is a full-document scan on every keystroke. The trade-off is
176
+ // that a document editing its way past the thresholds is not noticed until it next loads.
177
+ //
178
+ // Deliberately not skipped when limited mode is already on. Replacement is the one moment
179
+ // the document verdict can go *down* — live-to-live navigation onto a smaller page — so
180
+ // skipping it there is what would strand limited mode on forever. It costs one walk per
181
+ // page navigation, which is nothing next to the navigation itself.
182
+ if (documentReplaced) {
183
+ policy.evaluateDocument(tr.doc);
184
+ }
185
+
186
+ // Report bulk work so the policy can discount it. The policy decides what counts as bulk;
187
+ // this only supplies the facts.
188
+ //
189
+ // Known gap: operations that are expensive but barely change document size — table
190
+ // resize, drag-and-drop moves (delete + insert nets to ~0), type-ahead — are not
191
+ // suppressed. `@atlaskit/insm` already tracks exactly these via `startHeavyTask`, but its
192
+ // public facade does not expose `runningHeavyTasks`, so there is no way to read them from
193
+ // here today. Closing that gap needs an accessor on the insm package.
194
+ if (tr.docChanged) {
195
+ var _detector3;
196
+ (_detector3 = detector) === null || _detector3 === void 0 ? void 0 : _detector3.noteDocumentChange({
197
+ nodeSizeDelta: tr.doc.nodeSize - oldState.doc.nodeSize,
198
+ isDocumentReplaced: documentReplaced
199
+ });
200
+ }
201
+ const next = policyState(policy);
202
+
203
+ // Keep the previous object when nothing changed, so shared-state diffing stays cheap and
204
+ // consumers are not notified for no reason.
205
+ return next.enabled === currentPluginState.enabled && next.documentSizeBreachesThreshold === currentPluginState.documentSizeBreachesThreshold ? currentPluginState : next;
27
206
  }
28
207
  }
29
208
  });
@@ -0,0 +1,2 @@
1
+ import { PluginKey } from '@atlaskit/editor-prosemirror/state';
2
+ export const limitedModePluginKey = new PluginKey('limitedModePlugin');
@@ -0,0 +1,92 @@
1
+ import { isPerformanceAPIAvailable, isPerformanceObserverLongTaskAvailable } from '@atlaskit/editor-common/is-performance-api-available';
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 const createLatchDetector = ({
16
+ onLatchCriteriaMet,
17
+ policy
18
+ }) => {
19
+ // One clock for the whole subsystem, so measured durations and the policy's windows agree.
20
+ const now = policy.now;
21
+ let observer;
22
+ let destroyed = false;
23
+ const handleEvaluation = evaluation => {
24
+ if (evaluation !== 'latched') {
25
+ return;
26
+ }
27
+
28
+ // One-way latch: the policy will never evaluate again, so stop paying for the observers.
29
+ teardownObservers();
30
+ const details = policy.getLatchDetails();
31
+ if (details) {
32
+ onLatchCriteriaMet(details);
33
+ }
34
+ };
35
+ function teardownObservers() {
36
+ var _observer;
37
+ (_observer = observer) === null || _observer === void 0 ? void 0 : _observer.disconnect();
38
+ observer = undefined;
39
+ }
40
+ if (isPerformanceObserverLongTaskAvailable()) {
41
+ try {
42
+ observer = new PerformanceObserver(list => {
43
+ for (const entry of list.getEntries()) {
44
+ if (policy.isLatched()) {
45
+ return;
46
+ }
47
+ handleEvaluation(policy.recordLongTask(entry.duration));
48
+ }
49
+ });
50
+ observer.observe({
51
+ entryTypes: ['longtask']
52
+ });
53
+ } catch {
54
+ // `longtask` is unsupported in some browsers even when PerformanceObserver exists. The
55
+ // latency criterion alone is still a valid trigger, so carry on without freeze detection.
56
+ observer = undefined;
57
+ }
58
+ }
59
+ return {
60
+ noteDocumentChange: ({
61
+ nodeSizeDelta,
62
+ isDocumentReplaced
63
+ }) => {
64
+ if (destroyed || policy.isLatched()) {
65
+ return;
66
+ }
67
+ if (isDocumentReplaced || policy.isBulkChange(nodeSizeDelta)) {
68
+ policy.suppress();
69
+ }
70
+ },
71
+ measureInput: () => {
72
+ if (destroyed || policy.isLatched() || !isPerformanceAPIAvailable()) {
73
+ return;
74
+ }
75
+ const start = now();
76
+
77
+ // Runs after every handleTextInput and all resulting plugin work, but before paint — the
78
+ // same measurement point frozen-editor uses, so the numbers are comparable to existing
79
+ // SLOW_INPUT telemetry.
80
+ requestAnimationFrame(() => {
81
+ if (destroyed || policy.isLatched()) {
82
+ return;
83
+ }
84
+ handleEvaluation(policy.recordInputLatency(now() - start));
85
+ });
86
+ },
87
+ destroy: () => {
88
+ destroyed = true;
89
+ teardownObservers();
90
+ }
91
+ };
92
+ };