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