@atlaskit/editor-plugin-collab-edit 9.0.20 → 9.0.22

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # @atlaskit/editor-plugin-collab-edit
2
2
 
3
+ ## 9.0.22
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies
8
+
9
+ ## 9.0.21
10
+
11
+ ### Patch Changes
12
+
13
+ - [`f8922537e5ec8`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/f8922537e5ec8) -
14
+ Preserve node referential identity in replaceDocument to prevent ProseMirror view reconciliation
15
+ from unnecessarily destroying and recreating mark wrappers (and their React nodeviews), which
16
+ caused visible flicker on sync blocks and other wrapped nodeviews during collab initialization.
17
+
18
+ Fixes EDITOR-5277.
19
+
20
+ - Updated dependencies
21
+
3
22
  ## 9.0.20
4
23
 
5
24
  ### Patch Changes
@@ -0,0 +1,91 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.preserveNodeIdentity = preserveNodeIdentity;
7
+ var _model = require("@atlaskit/editor-prosemirror/model");
8
+ var _expValEquals = require("@atlaskit/tmp-editor-statsig/exp-val-equals");
9
+ /**
10
+ * Walk two Fragments (old and new) and return a Fragment that reuses old node
11
+ * references wherever structurally equal (`node.eq()` returns true).
12
+ *
13
+ * This preserves referential identity (`===`) for unchanged subtrees, which is
14
+ * critical for ProseMirror's view reconciliation performance. The `preMatch`
15
+ * optimisation in `prosemirror-view/src/viewdesc.ts` uses `===` to fast-match
16
+ * nodes against existing view descriptors. When all nodes are fresh objects
17
+ * (e.g. after `Node.fromJSON` in `replaceDocument`), `preMatch` fails for
18
+ * everything, and the fallback scan in `syncToMarks` is limited to 3
19
+ * positions — causing mark wrappers to be destroyed and recreated when widget
20
+ * decorations (gap cursor, telepointers, block controls) shift indices beyond
21
+ * that window. The destroyed mark wrappers take their React nodeviews with
22
+ * them, causing visible flicker.
23
+ *
24
+ * By preserving identity here, we ensure `preMatch` succeeds for unchanged
25
+ * subtrees, preventing unnecessary mark wrapper destruction and React nodeview
26
+ * re-mounting.
27
+ *
28
+ * @param oldFragment - Fragment from the current editor state (holds existing node references)
29
+ * @param newFragment - Fragment parsed from incoming document (fresh node objects)
30
+ * @returns A Fragment that reuses old node references where possible
31
+ *
32
+ * @see https://hello.jira.atlassian.cloud/browse/EDITOR-5277
33
+ * @see https://hello.jira.atlassian.cloud/browse/EDITOR-4424
34
+ */
35
+ function preserveNodeIdentity(oldFragment, newFragment) {
36
+ // Fast path: referentially identical — nothing to do
37
+ if (oldFragment === newFragment) {
38
+ return oldFragment;
39
+ }
40
+
41
+ // Fast path: structurally equal — reuse old fragment entirely.
42
+ // This covers the common SSR case where collab init sends the same doc.
43
+ if (oldFragment.eq(newFragment)) {
44
+ return oldFragment;
45
+ }
46
+
47
+ // Walk children position-by-position and preserve what we can
48
+ var oldCount = oldFragment.childCount;
49
+ var newCount = newFragment.childCount;
50
+ var changed = false;
51
+ var children = [];
52
+ for (var i = 0; i < newCount; i++) {
53
+ var newChild = newFragment.child(i);
54
+ if (i < oldCount) {
55
+ var oldChild = oldFragment.child(i);
56
+ if (oldChild === newChild) {
57
+ // Already referentially identical
58
+ children.push(newChild);
59
+ } else if (oldChild.eq(newChild)) {
60
+ // Structurally equal — reuse old reference (THE KEY OPERATION)
61
+ children.push(oldChild);
62
+ } else if (oldChild.type === newChild.type && oldChild.sameMarkup(newChild) && oldChild.content.childCount > 0 && newChild.content.childCount > 0 && (0, _expValEquals.expValEquals)('platform_editor_preserve_node_identity', 'isRecursive', true)) {
63
+ // Same type and markup but different content — recurse into children
64
+ var preservedContent = preserveNodeIdentity(oldChild.content, newChild.content);
65
+ if (preservedContent === oldChild.content) {
66
+ // All content was preserved — reuse old node entirely
67
+ children.push(oldChild);
68
+ } else {
69
+ // Partially changed — create node with preserved content
70
+ children.push(oldChild.copy(preservedContent));
71
+ changed = true;
72
+ }
73
+ } else {
74
+ // Completely different node — use new
75
+ children.push(newChild);
76
+ changed = true;
77
+ }
78
+ } else {
79
+ // New child beyond old count — use as-is (insertion)
80
+ children.push(newChild);
81
+ changed = true;
82
+ }
83
+ }
84
+
85
+ // If nothing changed and counts match, return old fragment directly
86
+ // (avoids creating a new Fragment object)
87
+ if (!changed && oldCount === newCount) {
88
+ return oldFragment;
89
+ }
90
+ return _model.Fragment.from(children);
91
+ }
@@ -17,6 +17,8 @@ var _state = require("@atlaskit/editor-prosemirror/state");
17
17
  var _transform = require("@atlaskit/editor-prosemirror/transform");
18
18
  var _view = require("@atlaskit/editor-prosemirror/view");
19
19
  var _editorSharedStyles = require("@atlaskit/editor-shared-styles");
20
+ var _experiments = require("@atlaskit/tmp-editor-statsig/experiments");
21
+ var _preserveNodeIdentity = require("./preserve-node-identity");
20
22
  var findPointers = exports.findPointers = function findPointers(id, decorations) {
21
23
  return decorations.find().reduce(function (arr, deco) {
22
24
  return deco.spec.pointer.presenceId === id ? arr.concat(deco) : arr;
@@ -110,15 +112,33 @@ var replaceDocument = exports.replaceDocument = function replaceDocument(doc, st
110
112
  var parsedDoc = (0, _processRawValue.processRawValueWithoutValidation)(schema, doc, editorAnalyticsAPI === null || editorAnalyticsAPI === void 0 ? void 0 : editorAnalyticsAPI.fireAnalyticsEvent);
111
113
  var hasContent = !!(parsedDoc !== null && parsedDoc !== void 0 && parsedDoc.childCount);
112
114
  var content = parsedDoc === null || parsedDoc === void 0 ? void 0 : parsedDoc.content;
113
- if (hasContent) {
115
+ if (hasContent && content && (0, _experiments.editorExperiment)('platform_editor_preserve_node_identity', true, {
116
+ exposure: true
117
+ })) {
118
+ var preservedContent = (0, _preserveNodeIdentity.preserveNodeIdentity)(state.doc.content, content);
119
+
120
+ // If the entire content is identical, skip the replaceWith entirely
121
+ // and just update collab metadata. This avoids triggering a full
122
+ // document reconciliation for no-op replacements.
123
+ if (preservedContent === state.doc.content) {
124
+ tr.setMeta('addToHistory', false);
125
+ if (version !== undefined && options && options.useNativePlugin) {
126
+ var collabState = {
127
+ version: version,
128
+ unconfirmed: []
129
+ };
130
+ tr.setMeta('collab$', collabState);
131
+ }
132
+ return tr;
133
+ }
134
+
135
+ // Use the preserved fragment (reuses old node references for unchanged nodes)
136
+ // rather than the raw parsed content, so ProseMirror's view reconciliation
137
+ // can fast-match unchanged subtrees via referential identity (===).
114
138
  tr.setMeta('addToHistory', false);
115
- // Ignored via go/ees005
116
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
117
- tr.replaceWith(0, state.doc.nodeSize - 2, content);
139
+ tr.replaceWith(0, state.doc.nodeSize - 2, preservedContent);
118
140
  var selection = state.selection;
119
141
  if (reserveCursor) {
120
- // If the cursor is still in the range of the new document,
121
- // keep where it was.
122
142
  if (selection.to < tr.doc.content.size - 2) {
123
143
  var $from = tr.doc.resolve(selection.from);
124
144
  var $to = tr.doc.resolve(selection.to);
@@ -129,12 +149,40 @@ var replaceDocument = exports.replaceDocument = function replaceDocument(doc, st
129
149
  tr.setSelection(_state.Selection.atStart(tr.doc));
130
150
  }
131
151
  tr.setMeta('replaceDocument', true);
152
+ if (version !== undefined && options && options.useNativePlugin) {
153
+ var _collabState = {
154
+ version: version,
155
+ unconfirmed: []
156
+ };
157
+ tr.setMeta('collab$', _collabState);
158
+ }
159
+ return tr;
160
+ }
161
+ if (hasContent) {
162
+ tr.setMeta('addToHistory', false);
163
+ // Ignored via go/ees005
164
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
165
+ tr.replaceWith(0, state.doc.nodeSize - 2, content);
166
+ var _selection = state.selection;
167
+ if (reserveCursor) {
168
+ // If the cursor is still in the range of the new document,
169
+ // keep where it was.
170
+ if (_selection.to < tr.doc.content.size - 2) {
171
+ var _$from = tr.doc.resolve(_selection.from);
172
+ var _$to = tr.doc.resolve(_selection.to);
173
+ var _newselection = new _state.TextSelection(_$from, _$to);
174
+ tr.setSelection(_newselection);
175
+ }
176
+ } else {
177
+ tr.setSelection(_state.Selection.atStart(tr.doc));
178
+ }
179
+ tr.setMeta('replaceDocument', true);
132
180
  if ((0, _typeof2.default)(version) !== undefined && options && options.useNativePlugin) {
133
- var collabState = {
181
+ var _collabState2 = {
134
182
  version: version,
135
183
  unconfirmed: []
136
184
  };
137
- tr.setMeta('collab$', collabState);
185
+ tr.setMeta('collab$', _collabState2);
138
186
  }
139
187
  }
140
188
  return tr;
@@ -0,0 +1,86 @@
1
+ import { Fragment } from '@atlaskit/editor-prosemirror/model';
2
+ import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
3
+
4
+ /**
5
+ * Walk two Fragments (old and new) and return a Fragment that reuses old node
6
+ * references wherever structurally equal (`node.eq()` returns true).
7
+ *
8
+ * This preserves referential identity (`===`) for unchanged subtrees, which is
9
+ * critical for ProseMirror's view reconciliation performance. The `preMatch`
10
+ * optimisation in `prosemirror-view/src/viewdesc.ts` uses `===` to fast-match
11
+ * nodes against existing view descriptors. When all nodes are fresh objects
12
+ * (e.g. after `Node.fromJSON` in `replaceDocument`), `preMatch` fails for
13
+ * everything, and the fallback scan in `syncToMarks` is limited to 3
14
+ * positions — causing mark wrappers to be destroyed and recreated when widget
15
+ * decorations (gap cursor, telepointers, block controls) shift indices beyond
16
+ * that window. The destroyed mark wrappers take their React nodeviews with
17
+ * them, causing visible flicker.
18
+ *
19
+ * By preserving identity here, we ensure `preMatch` succeeds for unchanged
20
+ * subtrees, preventing unnecessary mark wrapper destruction and React nodeview
21
+ * re-mounting.
22
+ *
23
+ * @param oldFragment - Fragment from the current editor state (holds existing node references)
24
+ * @param newFragment - Fragment parsed from incoming document (fresh node objects)
25
+ * @returns A Fragment that reuses old node references where possible
26
+ *
27
+ * @see https://hello.jira.atlassian.cloud/browse/EDITOR-5277
28
+ * @see https://hello.jira.atlassian.cloud/browse/EDITOR-4424
29
+ */
30
+ export function preserveNodeIdentity(oldFragment, newFragment) {
31
+ // Fast path: referentially identical — nothing to do
32
+ if (oldFragment === newFragment) {
33
+ return oldFragment;
34
+ }
35
+
36
+ // Fast path: structurally equal — reuse old fragment entirely.
37
+ // This covers the common SSR case where collab init sends the same doc.
38
+ if (oldFragment.eq(newFragment)) {
39
+ return oldFragment;
40
+ }
41
+
42
+ // Walk children position-by-position and preserve what we can
43
+ const oldCount = oldFragment.childCount;
44
+ const newCount = newFragment.childCount;
45
+ let changed = false;
46
+ const children = [];
47
+ for (let i = 0; i < newCount; i++) {
48
+ const newChild = newFragment.child(i);
49
+ if (i < oldCount) {
50
+ const oldChild = oldFragment.child(i);
51
+ if (oldChild === newChild) {
52
+ // Already referentially identical
53
+ children.push(newChild);
54
+ } else if (oldChild.eq(newChild)) {
55
+ // Structurally equal — reuse old reference (THE KEY OPERATION)
56
+ children.push(oldChild);
57
+ } else if (oldChild.type === newChild.type && oldChild.sameMarkup(newChild) && oldChild.content.childCount > 0 && newChild.content.childCount > 0 && expValEquals('platform_editor_preserve_node_identity', 'isRecursive', true)) {
58
+ // Same type and markup but different content — recurse into children
59
+ const preservedContent = preserveNodeIdentity(oldChild.content, newChild.content);
60
+ if (preservedContent === oldChild.content) {
61
+ // All content was preserved — reuse old node entirely
62
+ children.push(oldChild);
63
+ } else {
64
+ // Partially changed — create node with preserved content
65
+ children.push(oldChild.copy(preservedContent));
66
+ changed = true;
67
+ }
68
+ } else {
69
+ // Completely different node — use new
70
+ children.push(newChild);
71
+ changed = true;
72
+ }
73
+ } else {
74
+ // New child beyond old count — use as-is (insertion)
75
+ children.push(newChild);
76
+ changed = true;
77
+ }
78
+ }
79
+
80
+ // If nothing changed and counts match, return old fragment directly
81
+ // (avoids creating a new Fragment object)
82
+ if (!changed && oldCount === newCount) {
83
+ return oldFragment;
84
+ }
85
+ return Fragment.from(children);
86
+ }
@@ -7,6 +7,8 @@ import { Transaction, Selection, TextSelection } from '@atlaskit/editor-prosemir
7
7
  import { AttrStep, ReplaceStep } from '@atlaskit/editor-prosemirror/transform';
8
8
  import { Decoration } from '@atlaskit/editor-prosemirror/view';
9
9
  import { getParticipantColor } from '@atlaskit/editor-shared-styles';
10
+ import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments';
11
+ import { preserveNodeIdentity } from './preserve-node-identity';
10
12
  export const findPointers = (id, decorations) => decorations.find().reduce((arr, deco) => deco.spec.pointer.presenceId === id ? arr.concat(deco) : arr, []);
11
13
  function style(options) {
12
14
  const color = options && options.color || "var(--ds-border, #0B120E24)";
@@ -98,6 +100,52 @@ export const replaceDocument = (doc, state, version, options, reserveCursor, edi
98
100
  const parsedDoc = processRawValueWithoutValidation(schema, doc, editorAnalyticsAPI === null || editorAnalyticsAPI === void 0 ? void 0 : editorAnalyticsAPI.fireAnalyticsEvent);
99
101
  const hasContent = !!(parsedDoc !== null && parsedDoc !== void 0 && parsedDoc.childCount);
100
102
  const content = parsedDoc === null || parsedDoc === void 0 ? void 0 : parsedDoc.content;
103
+ if (hasContent && content && editorExperiment('platform_editor_preserve_node_identity', true, {
104
+ exposure: true
105
+ })) {
106
+ const preservedContent = preserveNodeIdentity(state.doc.content, content);
107
+
108
+ // If the entire content is identical, skip the replaceWith entirely
109
+ // and just update collab metadata. This avoids triggering a full
110
+ // document reconciliation for no-op replacements.
111
+ if (preservedContent === state.doc.content) {
112
+ tr.setMeta('addToHistory', false);
113
+ if (version !== undefined && options && options.useNativePlugin) {
114
+ const collabState = {
115
+ version,
116
+ unconfirmed: []
117
+ };
118
+ tr.setMeta('collab$', collabState);
119
+ }
120
+ return tr;
121
+ }
122
+
123
+ // Use the preserved fragment (reuses old node references for unchanged nodes)
124
+ // rather than the raw parsed content, so ProseMirror's view reconciliation
125
+ // can fast-match unchanged subtrees via referential identity (===).
126
+ tr.setMeta('addToHistory', false);
127
+ tr.replaceWith(0, state.doc.nodeSize - 2, preservedContent);
128
+ const selection = state.selection;
129
+ if (reserveCursor) {
130
+ if (selection.to < tr.doc.content.size - 2) {
131
+ const $from = tr.doc.resolve(selection.from);
132
+ const $to = tr.doc.resolve(selection.to);
133
+ const newselection = new TextSelection($from, $to);
134
+ tr.setSelection(newselection);
135
+ }
136
+ } else {
137
+ tr.setSelection(Selection.atStart(tr.doc));
138
+ }
139
+ tr.setMeta('replaceDocument', true);
140
+ if (version !== undefined && options && options.useNativePlugin) {
141
+ const collabState = {
142
+ version,
143
+ unconfirmed: []
144
+ };
145
+ tr.setMeta('collab$', collabState);
146
+ }
147
+ return tr;
148
+ }
101
149
  if (hasContent) {
102
150
  tr.setMeta('addToHistory', false);
103
151
  // Ignored via go/ees005
@@ -0,0 +1,86 @@
1
+ import { Fragment } from '@atlaskit/editor-prosemirror/model';
2
+ import { expValEquals } from '@atlaskit/tmp-editor-statsig/exp-val-equals';
3
+
4
+ /**
5
+ * Walk two Fragments (old and new) and return a Fragment that reuses old node
6
+ * references wherever structurally equal (`node.eq()` returns true).
7
+ *
8
+ * This preserves referential identity (`===`) for unchanged subtrees, which is
9
+ * critical for ProseMirror's view reconciliation performance. The `preMatch`
10
+ * optimisation in `prosemirror-view/src/viewdesc.ts` uses `===` to fast-match
11
+ * nodes against existing view descriptors. When all nodes are fresh objects
12
+ * (e.g. after `Node.fromJSON` in `replaceDocument`), `preMatch` fails for
13
+ * everything, and the fallback scan in `syncToMarks` is limited to 3
14
+ * positions — causing mark wrappers to be destroyed and recreated when widget
15
+ * decorations (gap cursor, telepointers, block controls) shift indices beyond
16
+ * that window. The destroyed mark wrappers take their React nodeviews with
17
+ * them, causing visible flicker.
18
+ *
19
+ * By preserving identity here, we ensure `preMatch` succeeds for unchanged
20
+ * subtrees, preventing unnecessary mark wrapper destruction and React nodeview
21
+ * re-mounting.
22
+ *
23
+ * @param oldFragment - Fragment from the current editor state (holds existing node references)
24
+ * @param newFragment - Fragment parsed from incoming document (fresh node objects)
25
+ * @returns A Fragment that reuses old node references where possible
26
+ *
27
+ * @see https://hello.jira.atlassian.cloud/browse/EDITOR-5277
28
+ * @see https://hello.jira.atlassian.cloud/browse/EDITOR-4424
29
+ */
30
+ export function preserveNodeIdentity(oldFragment, newFragment) {
31
+ // Fast path: referentially identical — nothing to do
32
+ if (oldFragment === newFragment) {
33
+ return oldFragment;
34
+ }
35
+
36
+ // Fast path: structurally equal — reuse old fragment entirely.
37
+ // This covers the common SSR case where collab init sends the same doc.
38
+ if (oldFragment.eq(newFragment)) {
39
+ return oldFragment;
40
+ }
41
+
42
+ // Walk children position-by-position and preserve what we can
43
+ var oldCount = oldFragment.childCount;
44
+ var newCount = newFragment.childCount;
45
+ var changed = false;
46
+ var children = [];
47
+ for (var i = 0; i < newCount; i++) {
48
+ var newChild = newFragment.child(i);
49
+ if (i < oldCount) {
50
+ var oldChild = oldFragment.child(i);
51
+ if (oldChild === newChild) {
52
+ // Already referentially identical
53
+ children.push(newChild);
54
+ } else if (oldChild.eq(newChild)) {
55
+ // Structurally equal — reuse old reference (THE KEY OPERATION)
56
+ children.push(oldChild);
57
+ } else if (oldChild.type === newChild.type && oldChild.sameMarkup(newChild) && oldChild.content.childCount > 0 && newChild.content.childCount > 0 && expValEquals('platform_editor_preserve_node_identity', 'isRecursive', true)) {
58
+ // Same type and markup but different content — recurse into children
59
+ var preservedContent = preserveNodeIdentity(oldChild.content, newChild.content);
60
+ if (preservedContent === oldChild.content) {
61
+ // All content was preserved — reuse old node entirely
62
+ children.push(oldChild);
63
+ } else {
64
+ // Partially changed — create node with preserved content
65
+ children.push(oldChild.copy(preservedContent));
66
+ changed = true;
67
+ }
68
+ } else {
69
+ // Completely different node — use new
70
+ children.push(newChild);
71
+ changed = true;
72
+ }
73
+ } else {
74
+ // New child beyond old count — use as-is (insertion)
75
+ children.push(newChild);
76
+ changed = true;
77
+ }
78
+ }
79
+
80
+ // If nothing changed and counts match, return old fragment directly
81
+ // (avoids creating a new Fragment object)
82
+ if (!changed && oldCount === newCount) {
83
+ return oldFragment;
84
+ }
85
+ return Fragment.from(children);
86
+ }
@@ -8,6 +8,8 @@ import { Transaction, Selection, TextSelection } from '@atlaskit/editor-prosemir
8
8
  import { AttrStep, ReplaceStep } from '@atlaskit/editor-prosemirror/transform';
9
9
  import { Decoration } from '@atlaskit/editor-prosemirror/view';
10
10
  import { getParticipantColor } from '@atlaskit/editor-shared-styles';
11
+ import { editorExperiment } from '@atlaskit/tmp-editor-statsig/experiments';
12
+ import { preserveNodeIdentity } from './preserve-node-identity';
11
13
  export var findPointers = function findPointers(id, decorations) {
12
14
  return decorations.find().reduce(function (arr, deco) {
13
15
  return deco.spec.pointer.presenceId === id ? arr.concat(deco) : arr;
@@ -101,15 +103,33 @@ export var replaceDocument = function replaceDocument(doc, state, version, optio
101
103
  var parsedDoc = processRawValueWithoutValidation(schema, doc, editorAnalyticsAPI === null || editorAnalyticsAPI === void 0 ? void 0 : editorAnalyticsAPI.fireAnalyticsEvent);
102
104
  var hasContent = !!(parsedDoc !== null && parsedDoc !== void 0 && parsedDoc.childCount);
103
105
  var content = parsedDoc === null || parsedDoc === void 0 ? void 0 : parsedDoc.content;
104
- if (hasContent) {
106
+ if (hasContent && content && editorExperiment('platform_editor_preserve_node_identity', true, {
107
+ exposure: true
108
+ })) {
109
+ var preservedContent = preserveNodeIdentity(state.doc.content, content);
110
+
111
+ // If the entire content is identical, skip the replaceWith entirely
112
+ // and just update collab metadata. This avoids triggering a full
113
+ // document reconciliation for no-op replacements.
114
+ if (preservedContent === state.doc.content) {
115
+ tr.setMeta('addToHistory', false);
116
+ if (version !== undefined && options && options.useNativePlugin) {
117
+ var collabState = {
118
+ version: version,
119
+ unconfirmed: []
120
+ };
121
+ tr.setMeta('collab$', collabState);
122
+ }
123
+ return tr;
124
+ }
125
+
126
+ // Use the preserved fragment (reuses old node references for unchanged nodes)
127
+ // rather than the raw parsed content, so ProseMirror's view reconciliation
128
+ // can fast-match unchanged subtrees via referential identity (===).
105
129
  tr.setMeta('addToHistory', false);
106
- // Ignored via go/ees005
107
- // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
108
- tr.replaceWith(0, state.doc.nodeSize - 2, content);
130
+ tr.replaceWith(0, state.doc.nodeSize - 2, preservedContent);
109
131
  var selection = state.selection;
110
132
  if (reserveCursor) {
111
- // If the cursor is still in the range of the new document,
112
- // keep where it was.
113
133
  if (selection.to < tr.doc.content.size - 2) {
114
134
  var $from = tr.doc.resolve(selection.from);
115
135
  var $to = tr.doc.resolve(selection.to);
@@ -120,12 +140,40 @@ export var replaceDocument = function replaceDocument(doc, state, version, optio
120
140
  tr.setSelection(Selection.atStart(tr.doc));
121
141
  }
122
142
  tr.setMeta('replaceDocument', true);
143
+ if (version !== undefined && options && options.useNativePlugin) {
144
+ var _collabState = {
145
+ version: version,
146
+ unconfirmed: []
147
+ };
148
+ tr.setMeta('collab$', _collabState);
149
+ }
150
+ return tr;
151
+ }
152
+ if (hasContent) {
153
+ tr.setMeta('addToHistory', false);
154
+ // Ignored via go/ees005
155
+ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
156
+ tr.replaceWith(0, state.doc.nodeSize - 2, content);
157
+ var _selection = state.selection;
158
+ if (reserveCursor) {
159
+ // If the cursor is still in the range of the new document,
160
+ // keep where it was.
161
+ if (_selection.to < tr.doc.content.size - 2) {
162
+ var _$from = tr.doc.resolve(_selection.from);
163
+ var _$to = tr.doc.resolve(_selection.to);
164
+ var _newselection = new TextSelection(_$from, _$to);
165
+ tr.setSelection(_newselection);
166
+ }
167
+ } else {
168
+ tr.setSelection(Selection.atStart(tr.doc));
169
+ }
170
+ tr.setMeta('replaceDocument', true);
123
171
  if (_typeof(version) !== undefined && options && options.useNativePlugin) {
124
- var collabState = {
172
+ var _collabState2 = {
125
173
  version: version,
126
174
  unconfirmed: []
127
175
  };
128
- tr.setMeta('collab$', collabState);
176
+ tr.setMeta('collab$', _collabState2);
129
177
  }
130
178
  }
131
179
  return tr;
@@ -0,0 +1,28 @@
1
+ import { Fragment } from '@atlaskit/editor-prosemirror/model';
2
+ /**
3
+ * Walk two Fragments (old and new) and return a Fragment that reuses old node
4
+ * references wherever structurally equal (`node.eq()` returns true).
5
+ *
6
+ * This preserves referential identity (`===`) for unchanged subtrees, which is
7
+ * critical for ProseMirror's view reconciliation performance. The `preMatch`
8
+ * optimisation in `prosemirror-view/src/viewdesc.ts` uses `===` to fast-match
9
+ * nodes against existing view descriptors. When all nodes are fresh objects
10
+ * (e.g. after `Node.fromJSON` in `replaceDocument`), `preMatch` fails for
11
+ * everything, and the fallback scan in `syncToMarks` is limited to 3
12
+ * positions — causing mark wrappers to be destroyed and recreated when widget
13
+ * decorations (gap cursor, telepointers, block controls) shift indices beyond
14
+ * that window. The destroyed mark wrappers take their React nodeviews with
15
+ * them, causing visible flicker.
16
+ *
17
+ * By preserving identity here, we ensure `preMatch` succeeds for unchanged
18
+ * subtrees, preventing unnecessary mark wrapper destruction and React nodeview
19
+ * re-mounting.
20
+ *
21
+ * @param oldFragment - Fragment from the current editor state (holds existing node references)
22
+ * @param newFragment - Fragment parsed from incoming document (fresh node objects)
23
+ * @returns A Fragment that reuses old node references where possible
24
+ *
25
+ * @see https://hello.jira.atlassian.cloud/browse/EDITOR-5277
26
+ * @see https://hello.jira.atlassian.cloud/browse/EDITOR-4424
27
+ */
28
+ export declare function preserveNodeIdentity(oldFragment: Fragment, newFragment: Fragment): Fragment;
@@ -8,8 +8,8 @@ import type { DecorationSet, EditorView } from '@atlaskit/editor-prosemirror/vie
8
8
  import { Decoration } from '@atlaskit/editor-prosemirror/view';
9
9
  export declare const findPointers: (id: string, decorations: DecorationSet) => Decoration[];
10
10
  export declare function getAvatarColor(str: string): {
11
- index: number;
12
11
  backgroundColor: string;
12
+ index: number;
13
13
  textColor: string;
14
14
  };
15
15
  export declare const createTelepointers: (from: number, to: number, sessionId: string, isSelection: boolean, initial: string, presenceId: string, fullName: string, isNudged: boolean) => Decoration[];
@@ -0,0 +1,28 @@
1
+ import { Fragment } from '@atlaskit/editor-prosemirror/model';
2
+ /**
3
+ * Walk two Fragments (old and new) and return a Fragment that reuses old node
4
+ * references wherever structurally equal (`node.eq()` returns true).
5
+ *
6
+ * This preserves referential identity (`===`) for unchanged subtrees, which is
7
+ * critical for ProseMirror's view reconciliation performance. The `preMatch`
8
+ * optimisation in `prosemirror-view/src/viewdesc.ts` uses `===` to fast-match
9
+ * nodes against existing view descriptors. When all nodes are fresh objects
10
+ * (e.g. after `Node.fromJSON` in `replaceDocument`), `preMatch` fails for
11
+ * everything, and the fallback scan in `syncToMarks` is limited to 3
12
+ * positions — causing mark wrappers to be destroyed and recreated when widget
13
+ * decorations (gap cursor, telepointers, block controls) shift indices beyond
14
+ * that window. The destroyed mark wrappers take their React nodeviews with
15
+ * them, causing visible flicker.
16
+ *
17
+ * By preserving identity here, we ensure `preMatch` succeeds for unchanged
18
+ * subtrees, preventing unnecessary mark wrapper destruction and React nodeview
19
+ * re-mounting.
20
+ *
21
+ * @param oldFragment - Fragment from the current editor state (holds existing node references)
22
+ * @param newFragment - Fragment parsed from incoming document (fresh node objects)
23
+ * @returns A Fragment that reuses old node references where possible
24
+ *
25
+ * @see https://hello.jira.atlassian.cloud/browse/EDITOR-5277
26
+ * @see https://hello.jira.atlassian.cloud/browse/EDITOR-4424
27
+ */
28
+ export declare function preserveNodeIdentity(oldFragment: Fragment, newFragment: Fragment): Fragment;
@@ -8,8 +8,8 @@ import type { DecorationSet, EditorView } from '@atlaskit/editor-prosemirror/vie
8
8
  import { Decoration } from '@atlaskit/editor-prosemirror/view';
9
9
  export declare const findPointers: (id: string, decorations: DecorationSet) => Decoration[];
10
10
  export declare function getAvatarColor(str: string): {
11
- index: number;
12
11
  backgroundColor: string;
12
+ index: number;
13
13
  textColor: string;
14
14
  };
15
15
  export declare const createTelepointers: (from: number, to: number, sessionId: string, isSelection: boolean, initial: string, presenceId: string, fullName: string, isNudged: boolean) => Decoration[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlaskit/editor-plugin-collab-edit",
3
- "version": "9.0.20",
3
+ "version": "9.0.22",
4
4
  "description": "Collab Edit plugin for @atlaskit/editor-core",
5
5
  "author": "Atlassian Pty Ltd",
6
6
  "license": "Apache-2.0",
@@ -39,13 +39,13 @@
39
39
  "@atlaskit/frontend-utilities": "^3.2.0",
40
40
  "@atlaskit/platform-feature-flags": "^1.1.0",
41
41
  "@atlaskit/prosemirror-collab": "^0.22.0",
42
- "@atlaskit/tmp-editor-statsig": "^53.0.0",
42
+ "@atlaskit/tmp-editor-statsig": "^54.0.0",
43
43
  "@atlaskit/tokens": "^11.4.0",
44
44
  "@babel/runtime": "^7.0.0",
45
45
  "memoize-one": "^6.0.0"
46
46
  },
47
47
  "peerDependencies": {
48
- "@atlaskit/editor-common": "^112.15.0",
48
+ "@atlaskit/editor-common": "^112.16.0",
49
49
  "react": "^18.2.0",
50
50
  "react-dom": "^18.2.0"
51
51
  },