@atlaskit/editor-plugin-collab-edit 23.0.0 → 23.0.1

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 (30) hide show
  1. package/CHANGELOG.md +45 -0
  2. package/dist/cjs/collabEditPlugin.js +25 -23
  3. package/dist/cjs/pm-plugins/collabSendHold.js +76 -0
  4. package/dist/cjs/pm-plugins/collapseStreamingSteps.js +127 -0
  5. package/dist/cjs/pm-plugins/events/send-transaction.js +13 -2
  6. package/dist/cjs/pm-plugins/mergeOverlappingSteps.js +152 -0
  7. package/dist/cjs/pm-plugins/track-steps.js +1 -104
  8. package/dist/es2019/collabEditPlugin.js +23 -23
  9. package/dist/es2019/pm-plugins/collabSendHold.js +68 -0
  10. package/dist/es2019/pm-plugins/collapseStreamingSteps.js +114 -0
  11. package/dist/es2019/pm-plugins/events/send-transaction.js +12 -2
  12. package/dist/es2019/pm-plugins/mergeOverlappingSteps.js +144 -0
  13. package/dist/es2019/pm-plugins/track-steps.js +0 -93
  14. package/dist/esm/collabEditPlugin.js +25 -23
  15. package/dist/esm/pm-plugins/collabSendHold.js +70 -0
  16. package/dist/esm/pm-plugins/collapseStreamingSteps.js +122 -0
  17. package/dist/esm/pm-plugins/events/send-transaction.js +13 -2
  18. package/dist/esm/pm-plugins/mergeOverlappingSteps.js +147 -0
  19. package/dist/esm/pm-plugins/track-steps.js +0 -104
  20. package/dist/types/pm-plugins/collabSendHold.d.ts +51 -0
  21. package/dist/types/pm-plugins/collapseStreamingSteps.d.ts +18 -0
  22. package/dist/types/pm-plugins/events/send-transaction.d.ts +7 -1
  23. package/dist/types/pm-plugins/mergeOverlappingSteps.d.ts +28 -0
  24. package/dist/types/pm-plugins/track-steps.d.ts +0 -41
  25. package/package.json +8 -5
  26. package/tsconfig.typecheck-typecheck-dev.tsbuildinfo +1 -0
  27. package/dist/cjs/pm-plugins/track-step-metrics.js +0 -82
  28. package/dist/es2019/pm-plugins/track-step-metrics.js +0 -79
  29. package/dist/esm/pm-plugins/track-step-metrics.js +0 -75
  30. package/dist/types/pm-plugins/track-step-metrics.d.ts +0 -48
@@ -4,8 +4,9 @@ import { JSONTransformer } from '@atlaskit/editor-json-transformer/JSONTransform
4
4
  import { AddMarkStep, AddNodeMarkStep } from '@atlaskit/editor-prosemirror/transform';
5
5
  import { fg } from '@atlaskit/platform-feature-flags/fg';
6
6
  import { collab, getCollabState, sendableSteps } from '@atlaskit/prosemirror-collab';
7
- import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
8
7
  import { addSynchronyErrorAnalytics } from './pm-plugins/analytics';
8
+ import { createCollabSendHold } from './pm-plugins/collabSendHold';
9
+ import { collapseStreamingSteps } from './pm-plugins/collapseStreamingSteps';
9
10
  import { sendTransaction } from './pm-plugins/events/send-transaction';
10
11
  import { filterAnalyticsSteps } from './pm-plugins/filterAnalytics';
11
12
  import { createPlugin } from './pm-plugins/main';
@@ -17,7 +18,6 @@ import { sanitizeFilteredStep, createPlugin as trackSpammingStepsPlugin } from '
17
18
  import { createPlugin as createLastOrganicChangePlugin, trackLastOrganicChangePluginKey } from './pm-plugins/track-last-organic-change';
18
19
  import { createPlugin as createTrackNCSInitializationPlugin, trackNCSInitializationPluginKey } from './pm-plugins/track-ncs-initialization';
19
20
  import { createPlugin as createTrackReconnectionConflictPlugin, trackLastRemoteConflictPluginKey } from './pm-plugins/track-reconnection-conflict';
20
- import { track } from './pm-plugins/track-steps';
21
21
  import { getAvatarColor } from './pm-plugins/utils';
22
22
  const providerBuilder = collabEditProviderPromise => async (codeToExecute, onError) => {
23
23
  try {
@@ -75,6 +75,9 @@ export const collabEditPlugin = ({
75
75
  providerResolver = _providerResolver;
76
76
  });
77
77
  const executeProviderCode = providerBuilder(collabEditProviderPromise);
78
+ // Spans transactions: a streaming producer holds the flush across many state updates, not just
79
+ // the ones it dispatches itself. See `createCollabSendHold`.
80
+ const sendHold = createCollabSendHold();
78
81
  return {
79
82
  name: 'collabEdit',
80
83
  getSharedState(state) {
@@ -160,7 +163,10 @@ export const collabEditPlugin = ({
160
163
  userId = null
161
164
  } = options || {};
162
165
  const transformUnconfirmed = steps => {
163
- const transformed = filterAnalyticsSteps(steps);
166
+ // Analytics steps are filtered first so they cannot break up an otherwise adjacent
167
+ // run of streaming frames.
168
+ const filtered = filterAnalyticsSteps(steps);
169
+ const transformed = fg('platform_editor_ai_collapse_streaming_steps') ? collapseStreamingSteps(filtered) : filtered;
164
170
  return mergeUnconfirmedSteps(transformed, api);
165
171
  };
166
172
  const plugins = [...(useNativePlugin ? [{
@@ -213,6 +219,16 @@ export const collabEditPlugin = ({
213
219
  },
214
220
  onEditorViewStateUpdated(props) {
215
221
  var _api$analytics2, _api$editorViewMode, _api$editorViewMode$s, _options$useNativePlu, _options$hideTelecurs;
222
+ // `props.transactions` is what `applyTransaction` produced: the dispatched transaction
223
+ // followed by everything `appendTransaction` added. Streaming frames are built in an
224
+ // appendTransaction, so their hold metadata is never on `originalTransaction`.
225
+ // Evaluated once and used for both the observation and the send decision. If the gate
226
+ // is turned off mid-stream, an already-active hold must stop applying immediately
227
+ // rather than persisting until the backstop expires and blocking every send.
228
+ const collapseStreamingStepsEnabled = fg('platform_editor_ai_collapse_streaming_steps');
229
+ if (collapseStreamingStepsEnabled) {
230
+ props.transactions.forEach(transaction => sendHold.observe(transaction));
231
+ }
216
232
  const addErrorAnalytics = addSynchronyErrorAnalytics(props.newEditorState, props.newEditorState.tr, featureFlags, api === null || api === void 0 ? void 0 : (_api$analytics2 = api.analytics) === null || _api$analytics2 === void 0 ? void 0 : _api$analytics2.actions);
217
233
  const isEmptyDoc = isEmptyDocument(props.newEditorState.doc);
218
234
  const viewMode = api === null || api === void 0 ? void 0 : (_api$editorViewMode = api.editorViewMode) === null || _api$editorViewMode === void 0 ? void 0 : (_api$editorViewMode$s = _api$editorViewMode.sharedState.currentState()) === null || _api$editorViewMode$s === void 0 ? void 0 : _api$editorViewMode$s.mode;
@@ -223,32 +239,16 @@ export const collabEditPlugin = ({
223
239
  newEditorState: props.newEditorState,
224
240
  useNativePlugin: (_options$useNativePlu = options.useNativePlugin) !== null && _options$useNativePlu !== void 0 ? _options$useNativePlu : false,
225
241
  hideTelecursorOnLoad: !isEmptyDoc && ((_options$hideTelecurs = options.hideTelecursorOnLoad) !== null && _options$hideTelecurs !== void 0 ? _options$hideTelecurs : false),
226
- viewMode
242
+ viewMode,
243
+ isSendHeld: collapseStreamingStepsEnabled && sendHold.isHeld()
227
244
  }), addErrorAnalytics);
228
- if (!expValEquals('platform_editor_remove_collab_step_metrics', 'isEnabled', true)) {
229
- track({
230
- api,
231
- ...props,
232
- onTrackDataProcessed: steps => {
233
- var _api$analytics3, _api$analytics3$actio;
234
- api === null || api === void 0 ? void 0 : (_api$analytics3 = api.analytics) === null || _api$analytics3 === void 0 ? void 0 : (_api$analytics3$actio = _api$analytics3.actions) === null || _api$analytics3$actio === void 0 ? void 0 : _api$analytics3$actio.fireAnalyticsEvent({
235
- action: ACTION.STEPS_TRACKED,
236
- actionSubject: ACTION_SUBJECT.COLLAB,
237
- attributes: {
238
- steps
239
- },
240
- eventType: EVENT_TYPE.OPERATIONAL
241
- });
242
- }
243
- });
244
- }
245
245
  if (fg('platform_editor_collab_organic_change_reporting')) {
246
246
  monitorOrganic({
247
247
  api,
248
248
  ...props,
249
249
  onDataProcessed: data => {
250
- var _api$analytics4, _api$analytics4$actio;
251
- api === null || api === void 0 ? void 0 : (_api$analytics4 = api.analytics) === null || _api$analytics4 === void 0 ? void 0 : (_api$analytics4$actio = _api$analytics4.actions) === null || _api$analytics4$actio === void 0 ? void 0 : _api$analytics4$actio.fireAnalyticsEvent({
250
+ var _api$analytics3, _api$analytics3$actio;
251
+ api === null || api === void 0 ? void 0 : (_api$analytics3 = api.analytics) === null || _api$analytics3 === void 0 ? void 0 : (_api$analytics3$actio = _api$analytics3.actions) === null || _api$analytics3$actio === void 0 ? void 0 : _api$analytics3$actio.fireAnalyticsEvent({
252
252
  action: ACTION.ORGANIC_CHANGES_TRACKED,
253
253
  actionSubject: ACTION_SUBJECT.COLLAB,
254
254
  attributes: {
@@ -0,0 +1,68 @@
1
+ import { DEFER_COLLAB_SEND } from '@atlaskit/editor-common/collab-defer-collab-send';
2
+ /**
3
+ * Upper bound on how long the send may stay held, measured from the transaction that *started* the
4
+ * hold rather than the most recent one.
5
+ *
6
+ * This is the only time bound on the hold. Producers decide when to release on semantic grounds —
7
+ * a completed block, the end of a stream — and this bounds how long that can take. It therefore
8
+ * doubles as the guarantee that a producer which never releases, because it aborted or threw
9
+ * between hold and release, cannot stop the editor sending for the rest of the session.
10
+ */
11
+ export const MAX_SEND_HOLD_MS = 2000;
12
+ /**
13
+ * Tracks whether sending to the collab service is currently held back.
14
+ *
15
+ * A producer that streams into the document needs several of its transactions to accumulate in the
16
+ * unconfirmed queue before anything is sent, so they can be composed into one step. Marking only
17
+ * the producer's own transactions is not enough: `sendTransaction` runs for *every* state update,
18
+ * and `provider.send` flushes and locks the entire queue regardless of which transaction triggered
19
+ * it. Acknowledgements from previous commits, remote steps, selection changes and the user's own
20
+ * typing all trigger it.
21
+ *
22
+ * So the hold has to be a state that spans transactions rather than a flag on one:
23
+ *
24
+ * - `tr.setMeta(DEFER_COLLAB_SEND, true)` starts the hold, and does *not* extend one already in
25
+ * progress
26
+ * - `tr.setMeta(DEFER_COLLAB_SEND, false)` releases it
27
+ * - anything else leaves it as it is
28
+ *
29
+ * Not extending matters. A producer marks every frame it wants held, so re-arming on each one
30
+ * would push the deadline back indefinitely and the bound would never be reached during an active
31
+ * stream. Measuring from the first held transaction instead makes the hold flush at least every
32
+ * {@link MAX_SEND_HOLD_MS}, with the next held frame starting a fresh window.
33
+ *
34
+ * While held, nothing is sent. Note this includes the local user's own edits: `sendableSteps`
35
+ * returns the queue in version order, so there is no way to send a step that sits behind held
36
+ * steps. Producers should keep hold windows short for that reason.
37
+ */
38
+ export const createCollabSendHold = () => {
39
+ let heldSince;
40
+ return {
41
+ observe: (transaction, now = Date.now()) => {
42
+ const meta = transaction.getMeta(DEFER_COLLAB_SEND);
43
+ if (meta === true) {
44
+ // Only start a window, never extend one, so a continuous stream of held frames
45
+ // still reaches the bound. See the note on this type about why.
46
+ if (heldSince === undefined) {
47
+ heldSince = now;
48
+ }
49
+ return;
50
+ }
51
+ if (meta === false) {
52
+ heldSince = undefined;
53
+ }
54
+ },
55
+ isHeld: (now = Date.now()) => {
56
+ if (heldSince === undefined) {
57
+ return false;
58
+ }
59
+ if (now - heldSince >= MAX_SEND_HOLD_MS) {
60
+ // Clear as well as report, so the next held frame opens a fresh window instead of
61
+ // being judged against the expired one.
62
+ heldSince = undefined;
63
+ return false;
64
+ }
65
+ return true;
66
+ }
67
+ };
68
+ };
@@ -0,0 +1,114 @@
1
+ import { STREAMING_COLLAPSIBLE_STEPS } from '@atlaskit/editor-common/collab-streaming-step-collapse';
2
+ import { Transaction } from '@atlaskit/editor-prosemirror/state';
3
+ import { Rebaseable } from '@atlaskit/prosemirror-collab';
4
+ import { mergeOverlappingSteps } from './mergeOverlappingSteps';
5
+
6
+ /**
7
+ * Is this step an intermediate frame that its producer has marked as collapsible, and is it still
8
+ * free to be composed?
9
+ */
10
+ const isCollapsibleFrame = rebaseable => {
11
+ const {
12
+ origin
13
+ } = rebaseable;
14
+ if (!(origin instanceof Transaction) || origin.getMeta(STREAMING_COLLAPSIBLE_STEPS) !== true) {
15
+ return false;
16
+ }
17
+
18
+ // Already sent to (or queued for) the collab service — composing it now would change a step the
19
+ // backend has already seen. Mirrors the `mergeIsLocked` contract in `mergeUnconfirmedSteps`.
20
+ return !origin.getMeta('mergeIsLocked');
21
+ };
22
+
23
+ /**
24
+ * Starting document of each composed step this module has produced.
25
+ *
26
+ * A composed step is synthesised rather than taken from a transaction, so it cannot be found in
27
+ * `origin.steps` and its starting document cannot be recovered the way an original frame's can.
28
+ * `transformUnconfirmed` runs once per transaction and is handed back the steps it returned last
29
+ * time, so the composed step needs to carry its document forward to keep composing against later
30
+ * frames.
31
+ *
32
+ * Keyed on the step rather than its Rebaseable wrapper. `mergeUnconfirmedSteps` runs immediately
33
+ * after this module in `transformUnconfirmed` and rebuilds every wrapper it is given — merging
34
+ * enabled or not — so a wrapper-keyed entry would be unreachable by the very next pass. The step
35
+ * survives that rebuild untouched, and is still replaced whenever the association genuinely
36
+ * becomes stale: `rebaseSteps` maps steps into new objects, and a real merge composes a new one.
37
+ */
38
+ const composedStepDocs = new WeakMap();
39
+
40
+ /**
41
+ * The document this step was applied to.
42
+ *
43
+ * `transformUnconfirmed` is handed steps and nothing else, but a Rebaseable keeps a reference to
44
+ * the transaction that produced it, and a transaction records the document before each of its
45
+ * steps. That is enough for `mergeOverlappingSteps` to resolve merges it cannot derive from the
46
+ * steps alone — notably a frame that replaces more than just what the previous frame inserted.
47
+ *
48
+ * Rebasing maps a step into a new object while keeping the original transaction, so a rebased step
49
+ * is not found in `origin.steps` and correctly yields no document. `mergeOverlappingSteps` then
50
+ * falls back to the merges it can prove without one.
51
+ */
52
+ const docBeforeStep = rebaseable => {
53
+ const {
54
+ origin,
55
+ step
56
+ } = rebaseable;
57
+ const composed = composedStepDocs.get(step);
58
+ if (composed) {
59
+ return composed;
60
+ }
61
+ const index = origin.steps.indexOf(step);
62
+ return index >= 0 ? origin.docs[index] : undefined;
63
+ };
64
+
65
+ /**
66
+ * Compose adjacent streaming frames in the unconfirmed queue so the collab service receives one
67
+ * step per flush instead of one step per rendered chunk.
68
+ *
69
+ * This runs in the collab plugin's `transformUnconfirmed` hook, which fires *after* each frame has
70
+ * already been applied to the document. Collapsing therefore has no effect on what the local user
71
+ * sees, on undo history, or on any other plugin state — it only changes what is sent to the collab
72
+ * service, and what is used as the rebase buffer when remote steps arrive.
73
+ *
74
+ * Because the unconfirmed steps double as the rebase buffer (`rebaseSteps` applies their inverses
75
+ * to the live document), the composition must be exact. `mergeOverlappingSteps` only returns a
76
+ * merge it can prove exact, and this function degrades to a no-op whenever it declines.
77
+ *
78
+ * @param steps Rebaseable steps
79
+ * @returns Rebaseable steps, with adjacent streaming frames composed
80
+ */
81
+ export function collapseStreamingSteps(steps) {
82
+ // Fast path: nothing to do unless at least two frames could possibly compose.
83
+ if (steps.length < 2) {
84
+ return steps;
85
+ }
86
+ const collapsed = [];
87
+ // The document the run currently at the tail of `collapsed` started from. A composed step
88
+ // still applies to the document its first frame applied to, so this survives each merge.
89
+ let runDocBefore;
90
+ for (const rebaseable of steps) {
91
+ const previous = collapsed[collapsed.length - 1];
92
+ if (!previous || !isCollapsibleFrame(previous) || !isCollapsibleFrame(rebaseable)) {
93
+ collapsed.push(rebaseable);
94
+ runDocBefore = docBeforeStep(rebaseable);
95
+ continue;
96
+ }
97
+ const merged = mergeOverlappingSteps(previous, rebaseable, runDocBefore);
98
+ if (!merged) {
99
+ collapsed.push(rebaseable);
100
+ runDocBefore = docBeforeStep(rebaseable);
101
+ continue;
102
+ }
103
+
104
+ // Keep the earliest origin, matching `mergeUnconfirmedSteps`. `commitUnconfirmedSteps` in
105
+ // the collab provider tracks completion by origin identity, so keeping the earliest means
106
+ // the composed step is not considered confirmed until the whole run has been acknowledged.
107
+ const composed = new Rebaseable(merged.step, merged.inverted, previous.origin);
108
+ if (runDocBefore) {
109
+ composedStepDocs.set(composed.step, runDocBefore);
110
+ }
111
+ collapsed[collapsed.length - 1] = composed;
112
+ }
113
+ return collapsed;
114
+ }
@@ -8,7 +8,8 @@ export const sendTransaction = ({
8
8
  newEditorState,
9
9
  useNativePlugin,
10
10
  viewMode,
11
- hideTelecursorOnLoad
11
+ hideTelecursorOnLoad,
12
+ isSendHeld = false
12
13
  }) => provider => {
13
14
  const docChangedTransaction = transactions.find(tr => tr.docChanged);
14
15
  const currentPluginState = pluginKey.getState(newEditorState);
@@ -25,7 +26,16 @@ export const sendTransaction = ({
25
26
  // TODO: ED-8995 - We need to do this check to reduce the number of race conditions when working with tables.
26
27
  // This metadata is coming from the scaleTable command in table-resizing plugin
27
28
  !originalTransaction.getMeta('scaleTable') && trNoAnalytics.docChanged;
28
- if (useNativePlugin || shouldSendStepForSynchronyCollabProvider) {
29
+
30
+ // A streaming producer can hold the flush back so several of its frames accumulate in the
31
+ // unconfirmed queue and collapse into one step before being sent. The steps are not
32
+ // dropped: `sendableSteps` returns the whole queue, so the first unheld state update sends
33
+ // everything that accumulated. Skipping `send` also skips `lockStepOrigins`, which is what
34
+ // keeps the accumulated frames composable.
35
+ //
36
+ // Telepointer messages below are intentionally left alone — holding them would freeze
37
+ // other participants' cursors for the length of the hold, and they carry no steps.
38
+ if (!isSendHeld && (useNativePlugin || shouldSendStepForSynchronyCollabProvider)) {
29
39
  provider.send(trNoAnalytics, oldEditorState, newEditorState);
30
40
  }
31
41
  const prevPluginState = pluginKey.getState(oldEditorState);
@@ -0,0 +1,144 @@
1
+ import { ReplaceStep, Transform } from '@atlaskit/editor-prosemirror/transform';
2
+
3
+ /**
4
+ * A step paired with its inverse, as stored in the collab unconfirmed queue.
5
+ */
6
+
7
+ /**
8
+ * `structure` is `@internal` on ReplaceStep but is part of the serialised form, so this is the
9
+ * supported way to read it. Structural replaces exist to stop rebased steps from overwriting
10
+ * content they should not, so they are never merged.
11
+ */
12
+ const isStructural = step => step.toJSON().structure === true;
13
+ const asPlainReplaceStep = step =>
14
+ // Deliberately excludes ReplaceAroundStep — the position arithmetic below is only sound for a
15
+ // plain range replace.
16
+ step instanceof ReplaceStep && !isStructural(step) ? step : undefined;
17
+
18
+ /**
19
+ * Do the two steps touch the same region of the document?
20
+ *
21
+ * `second` is expressed in the coordinate space *after* `first` has been applied, so `first`'s
22
+ * footprint there is the range its slice occupies: `[from, from + slice.size]`.
23
+ */
24
+ const overlaps = (first, second) => {
25
+ const firstInsertedEnd = first.from + first.slice.size;
26
+ return second.from <= firstInsertedEnd && second.to >= first.from;
27
+ };
28
+
29
+ /**
30
+ * Merge using ProseMirror's own step merging, which handles adjacent insertions and adjacent
31
+ * deletions. Both the steps and their inverses have to merge for the result to be usable as a
32
+ * rebase buffer entry — inverses compose in reverse order.
33
+ */
34
+ const mergeNatively = (first, second) => {
35
+ const step = first.step.merge(second.step);
36
+ if (!step) {
37
+ return null;
38
+ }
39
+ const inverted = second.inverted.merge(first.inverted);
40
+ if (!inverted) {
41
+ return null;
42
+ }
43
+ return {
44
+ step,
45
+ inverted
46
+ };
47
+ };
48
+
49
+ /**
50
+ * Merge any two overlapping steps by applying both to the document and describing the net change
51
+ * as a single minimal replacement.
52
+ *
53
+ * This is the general case: it copes with `second` reaching past `first` on either side, with
54
+ * partial overlaps, and with slices whose open depths differ — none of which can be resolved from
55
+ * the steps alone, because the composed slice has to be cut out of a real document.
56
+ */
57
+ const mergeViaDocument = (first, second, docBefore) => {
58
+ let docAfter;
59
+ try {
60
+ const transform = new Transform(docBefore);
61
+ transform.step(first.step);
62
+ transform.step(second.step);
63
+ docAfter = transform.doc;
64
+ } catch {
65
+ // `docBefore` did not match the steps (for example the steps have since been rebased).
66
+ return null;
67
+ }
68
+ const start = docBefore.content.findDiffStart(docAfter.content);
69
+ if (start === null) {
70
+ // The steps cancel each other out. There is no single step representing "no change", so
71
+ // leave the pair alone rather than inventing one.
72
+ return null;
73
+ }
74
+ const diffEnd = docBefore.content.findDiffEnd(docAfter.content);
75
+ if (!diffEnd) {
76
+ return null;
77
+ }
78
+
79
+ // findDiffStart and findDiffEnd can overshoot each other when the same text appears on both
80
+ // sides of the change; push the end out so the range stays well formed.
81
+ let {
82
+ a: endBefore,
83
+ b: endAfter
84
+ } = diffEnd;
85
+ const overshoot = start - Math.min(endBefore, endAfter);
86
+ if (overshoot > 0) {
87
+ endBefore += overshoot;
88
+ endAfter += overshoot;
89
+ }
90
+ const step = new ReplaceStep(start, endBefore, docAfter.slice(start, endAfter));
91
+
92
+ // Cheap guarantee that the composition is exact. If it is not, keep the original pair.
93
+ const applied = step.apply(docBefore);
94
+ if (applied.failed !== null || !applied.doc || !applied.doc.eq(docAfter)) {
95
+ return null;
96
+ }
97
+ return {
98
+ step,
99
+ inverted: step.invert(docBefore)
100
+ };
101
+ };
102
+
103
+ /**
104
+ * Merge two consecutive steps into the single step they are collectively equivalent to.
105
+ *
106
+ * `second` must be expressed in the coordinate space produced by `first` — that is, the two must be
107
+ * consecutive steps of the same document. The returned step maps the document from before `first`
108
+ * to after `second`, and the returned inverse maps it back, so the result is safe to use both on
109
+ * the wire and as a rebase buffer entry.
110
+ *
111
+ * Returns `null` whenever the steps do not overlap, or when the merge cannot be proven exact. A
112
+ * `null` result always means "keep the two steps as they are", never "the steps are equivalent to
113
+ * nothing".
114
+ *
115
+ * @param first The earlier step and its inverse.
116
+ * @param second The later step and its inverse.
117
+ * @param docBefore The document `first` applies to. Optional, but without it only ProseMirror's own
118
+ * adjacent-step merges can be resolved, because the composed slice otherwise has to be cut out of a
119
+ * real document. Callers lose it when the queue has been rebased over a remote step.
120
+ */
121
+ export function mergeOverlappingSteps(first, second, docBefore) {
122
+ const firstStep = asPlainReplaceStep(first.step);
123
+ const secondStep = asPlainReplaceStep(second.step);
124
+
125
+ // Non-plain replaces are only ever merged by ProseMirror itself, which knows the rules for them.
126
+ if (!firstStep || !secondStep) {
127
+ return mergeNatively(first, second);
128
+ }
129
+ if (!overlaps(firstStep, secondStep)) {
130
+ return null;
131
+ }
132
+
133
+ // Try the O(1) strategy before the one that has to replay both steps against a document.
134
+ // `transformUnconfirmed` runs on every transaction, and adjacent insertions — which is what
135
+ // consecutive streaming frames often are — are exactly what ProseMirror merges natively.
136
+ const natively = mergeNatively(first, second);
137
+ if (natively) {
138
+ return natively;
139
+ }
140
+
141
+ // Anything else needs the document to cut the composed slice out of, so without one there is
142
+ // no merge that can be proven exact.
143
+ return docBefore ? mergeViaDocument(first, second, docBefore) : null;
144
+ }
@@ -1,10 +1,6 @@
1
- // delete this file when cleaning up platform_editor_remove_collab_step_metrics
2
- import { AnalyticsStep } from '@atlaskit/adf-schema/steps/analytics';
3
1
  import { BatchAttrsStep } from '@atlaskit/adf-schema/steps/batch-attrs-step';
4
2
  import { SetAttrsStep } from '@atlaskit/adf-schema/steps/set-attrs';
5
3
  import { AddMarkStep, AddNodeMarkStep, AttrStep, DocAttrStep, RemoveMarkStep, RemoveNodeMarkStep } from '@atlaskit/editor-prosemirror/transform';
6
- import { sendableSteps } from '@atlaskit/prosemirror-collab';
7
- import { updateNcsSessionStepMetrics } from './track-step-metrics';
8
4
  function groupBy(array, keyGetter) {
9
5
  // Check group by exists, and that it's a function. If so, use the native browser code
10
6
  if ('groupBy' in Object && typeof Object.groupBy === 'function') {
@@ -82,93 +78,4 @@ export const groupSteps = sanitizedSteps => {
82
78
  acc[key] = Array.isArray(value) ? value.length : 0;
83
79
  return acc;
84
80
  }, {});
85
- };
86
-
87
- /**
88
- * Processes the steps metadata from the cache and calls the callback function with the processed data.
89
- *
90
- * @param {CacheType} cache - A cache containing steps metadata.
91
- * @param {(data: StepMetadataAnalytics[]) => void} onTrackDataProcessed - Callback function to be called with the processed data.
92
- */
93
- export const task = (cache, onTrackDataProcessed) => {
94
- const stepsMetadata = [];
95
- for (const entry of cache.values()) {
96
- const {
97
- startedAt,
98
- endedAt,
99
- steps
100
- } = entry;
101
- const stepTypesAmount = groupSteps(steps.map(sanitizeStep));
102
- stepsMetadata.push({
103
- startedAt,
104
- endedAt,
105
- stepTypesAmount
106
- });
107
- }
108
- cache.clear();
109
- if (stepsMetadata.length > 0) {
110
- onTrackDataProcessed(stepsMetadata);
111
- }
112
- };
113
- const stepsSentCache = new Map();
114
- // Every ten seconds we will try to process the step data.
115
- const LOW_PRIORITY_DELAY = 10000;
116
-
117
- // See https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/
118
-
119
- const getScheduler = obj => {
120
- if (!obj) {
121
- return null;
122
- }
123
- if ('scheduler' in obj) {
124
- return obj.scheduler;
125
- }
126
- return null;
127
- };
128
-
129
- /**
130
- * Tracks the steps sent by the client by storing them in a cache and scheduling a task to process them. Once the steps are processed, the onTrackDataProcessed callabck will be called.
131
- *
132
- * This is a non-critical code. If the browser doesn't support the Scheduler API https://developer.mozilla.org/en-US/docs/Web/API/Scheduler/
133
- *
134
- * @param {TrackProps} props - The properties required for tracking steps.
135
- * @param {ExtractInjectionAPI<CollabEditPlugin> | undefined} props.api - The API for the CollabEdit plugin.
136
- * @param {EditorState} props.newEditorState - The new editor state.
137
- * @param {Readonly<Transaction[]>} props.transactions - The transactions that contain the steps.
138
- * @param {(data: StepMetadataAnalytics[]) => void} props.onTrackDataProcessed - Callback function to be called with the processed data.
139
- */
140
- export const track = ({
141
- api,
142
- newEditorState,
143
- transactions,
144
- onTrackDataProcessed
145
- }) => {
146
- const newSteps = transactions.flatMap(t => t.steps);
147
- const collabState = sendableSteps(newEditorState);
148
- const scheduler = getScheduler(window);
149
- if (!newSteps.length || !scheduler || !collabState) {
150
- return;
151
- }
152
- const {
153
- version
154
- } = collabState;
155
- const buffer = stepsSentCache.get(version);
156
- const startedAt = (buffer === null || buffer === void 0 ? void 0 : buffer.startedAt) || Date.now();
157
- const endedAt = Date.now();
158
- const steps = ((buffer === null || buffer === void 0 ? void 0 : buffer.steps) || []).concat(newSteps);
159
- stepsSentCache.set(version, {
160
- startedAt,
161
- endedAt,
162
- steps
163
- });
164
- updateNcsSessionStepMetrics({
165
- api,
166
- steps: newSteps.filter(step => !(step instanceof AnalyticsStep))
167
- });
168
- scheduler.postTask(() => {
169
- task(stepsSentCache, onTrackDataProcessed);
170
- }, {
171
- priority: 'background',
172
- delay: LOW_PRIORITY_DELAY
173
- });
174
81
  };
@@ -10,8 +10,9 @@ import { JSONTransformer } from '@atlaskit/editor-json-transformer/JSONTransform
10
10
  import { AddMarkStep, AddNodeMarkStep } from '@atlaskit/editor-prosemirror/transform';
11
11
  import { fg } from '@atlaskit/platform-feature-flags/fg';
12
12
  import { collab, getCollabState, sendableSteps } from '@atlaskit/prosemirror-collab';
13
- import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
14
13
  import { addSynchronyErrorAnalytics } from './pm-plugins/analytics';
14
+ import { createCollabSendHold } from './pm-plugins/collabSendHold';
15
+ import { collapseStreamingSteps } from './pm-plugins/collapseStreamingSteps';
15
16
  import { sendTransaction } from './pm-plugins/events/send-transaction';
16
17
  import { filterAnalyticsSteps } from './pm-plugins/filterAnalytics';
17
18
  import { createPlugin } from './pm-plugins/main';
@@ -23,7 +24,6 @@ import { sanitizeFilteredStep, createPlugin as trackSpammingStepsPlugin } from '
23
24
  import { createPlugin as createLastOrganicChangePlugin, trackLastOrganicChangePluginKey } from './pm-plugins/track-last-organic-change';
24
25
  import { createPlugin as createTrackNCSInitializationPlugin, trackNCSInitializationPluginKey } from './pm-plugins/track-ncs-initialization';
25
26
  import { createPlugin as createTrackReconnectionConflictPlugin, trackLastRemoteConflictPluginKey } from './pm-plugins/track-reconnection-conflict';
26
- import { track } from './pm-plugins/track-steps';
27
27
  import { getAvatarColor } from './pm-plugins/utils';
28
28
  var providerBuilder = function providerBuilder(collabEditProviderPromise) {
29
29
  return /*#__PURE__*/function () {
@@ -107,6 +107,9 @@ export var collabEditPlugin = function collabEditPlugin(_ref4) {
107
107
  providerResolver = _providerResolver;
108
108
  });
109
109
  var executeProviderCode = providerBuilder(collabEditProviderPromise);
110
+ // Spans transactions: a streaming producer holds the flush across many state updates, not just
111
+ // the ones it dispatches itself. See `createCollabSendHold`.
112
+ var sendHold = createCollabSendHold();
110
113
  return {
111
114
  name: 'collabEdit',
112
115
  getSharedState: function getSharedState(state) {
@@ -198,7 +201,10 @@ export var collabEditPlugin = function collabEditPlugin(_ref4) {
198
201
  _ref5$userId = _ref5.userId,
199
202
  userId = _ref5$userId === void 0 ? null : _ref5$userId;
200
203
  var transformUnconfirmed = function transformUnconfirmed(steps) {
201
- var transformed = filterAnalyticsSteps(steps);
204
+ // Analytics steps are filtered first so they cannot break up an otherwise adjacent
205
+ // run of streaming frames.
206
+ var filtered = filterAnalyticsSteps(steps);
207
+ var transformed = fg('platform_editor_ai_collapse_streaming_steps') ? collapseStreamingSteps(filtered) : filtered;
202
208
  return mergeUnconfirmedSteps(transformed, api);
203
209
  };
204
210
  var plugins = [].concat(_toConsumableArray(useNativePlugin ? [{
@@ -258,6 +264,18 @@ export var collabEditPlugin = function collabEditPlugin(_ref4) {
258
264
  },
259
265
  onEditorViewStateUpdated: function onEditorViewStateUpdated(props) {
260
266
  var _api$analytics2, _api$editorViewMode, _options$useNativePlu, _options$hideTelecurs;
267
+ // `props.transactions` is what `applyTransaction` produced: the dispatched transaction
268
+ // followed by everything `appendTransaction` added. Streaming frames are built in an
269
+ // appendTransaction, so their hold metadata is never on `originalTransaction`.
270
+ // Evaluated once and used for both the observation and the send decision. If the gate
271
+ // is turned off mid-stream, an already-active hold must stop applying immediately
272
+ // rather than persisting until the backstop expires and blocking every send.
273
+ var collapseStreamingStepsEnabled = fg('platform_editor_ai_collapse_streaming_steps');
274
+ if (collapseStreamingStepsEnabled) {
275
+ props.transactions.forEach(function (transaction) {
276
+ return sendHold.observe(transaction);
277
+ });
278
+ }
261
279
  var addErrorAnalytics = addSynchronyErrorAnalytics(props.newEditorState, props.newEditorState.tr, featureFlags, api === null || api === void 0 || (_api$analytics2 = api.analytics) === null || _api$analytics2 === void 0 ? void 0 : _api$analytics2.actions);
262
280
  var isEmptyDoc = isEmptyDocument(props.newEditorState.doc);
263
281
  var viewMode = api === null || api === void 0 || (_api$editorViewMode = api.editorViewMode) === null || _api$editorViewMode === void 0 || (_api$editorViewMode = _api$editorViewMode.sharedState.currentState()) === null || _api$editorViewMode === void 0 ? void 0 : _api$editorViewMode.mode;
@@ -268,32 +286,16 @@ export var collabEditPlugin = function collabEditPlugin(_ref4) {
268
286
  newEditorState: props.newEditorState,
269
287
  useNativePlugin: (_options$useNativePlu = options.useNativePlugin) !== null && _options$useNativePlu !== void 0 ? _options$useNativePlu : false,
270
288
  hideTelecursorOnLoad: !isEmptyDoc && ((_options$hideTelecurs = options.hideTelecursorOnLoad) !== null && _options$hideTelecurs !== void 0 ? _options$hideTelecurs : false),
271
- viewMode: viewMode
289
+ viewMode: viewMode,
290
+ isSendHeld: collapseStreamingStepsEnabled && sendHold.isHeld()
272
291
  }), addErrorAnalytics);
273
- if (!expValEquals('platform_editor_remove_collab_step_metrics', 'isEnabled', true)) {
274
- track(_objectSpread(_objectSpread({
275
- api: api
276
- }, props), {}, {
277
- onTrackDataProcessed: function onTrackDataProcessed(steps) {
278
- var _api$analytics3;
279
- api === null || api === void 0 || (_api$analytics3 = api.analytics) === null || _api$analytics3 === void 0 || (_api$analytics3 = _api$analytics3.actions) === null || _api$analytics3 === void 0 || _api$analytics3.fireAnalyticsEvent({
280
- action: ACTION.STEPS_TRACKED,
281
- actionSubject: ACTION_SUBJECT.COLLAB,
282
- attributes: {
283
- steps: steps
284
- },
285
- eventType: EVENT_TYPE.OPERATIONAL
286
- });
287
- }
288
- }));
289
- }
290
292
  if (fg('platform_editor_collab_organic_change_reporting')) {
291
293
  monitorOrganic(_objectSpread(_objectSpread({
292
294
  api: api
293
295
  }, props), {}, {
294
296
  onDataProcessed: function onDataProcessed(data) {
295
- var _api$analytics4;
296
- api === null || api === void 0 || (_api$analytics4 = api.analytics) === null || _api$analytics4 === void 0 || (_api$analytics4 = _api$analytics4.actions) === null || _api$analytics4 === void 0 || _api$analytics4.fireAnalyticsEvent({
297
+ var _api$analytics3;
298
+ api === null || api === void 0 || (_api$analytics3 = api.analytics) === null || _api$analytics3 === void 0 || (_api$analytics3 = _api$analytics3.actions) === null || _api$analytics3 === void 0 || _api$analytics3.fireAnalyticsEvent({
297
299
  action: ACTION.ORGANIC_CHANGES_TRACKED,
298
300
  actionSubject: ACTION_SUBJECT.COLLAB,
299
301
  attributes: {