@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 +19 -0
- package/dist/cjs/pm-plugins/preserve-node-identity.js +91 -0
- package/dist/cjs/pm-plugins/utils.js +56 -8
- package/dist/es2019/pm-plugins/preserve-node-identity.js +86 -0
- package/dist/es2019/pm-plugins/utils.js +48 -0
- package/dist/esm/pm-plugins/preserve-node-identity.js +86 -0
- package/dist/esm/pm-plugins/utils.js +56 -8
- package/dist/types/pm-plugins/preserve-node-identity.d.ts +28 -0
- package/dist/types/pm-plugins/utils.d.ts +1 -1
- package/dist/types-ts4.5/pm-plugins/preserve-node-identity.d.ts +28 -0
- package/dist/types-ts4.5/pm-plugins/utils.d.ts +1 -1
- package/package.json +3 -3
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
|
-
|
|
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
|
|
181
|
+
var _collabState2 = {
|
|
134
182
|
version: version,
|
|
135
183
|
unconfirmed: []
|
|
136
184
|
};
|
|
137
|
-
tr.setMeta('collab$',
|
|
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
|
-
|
|
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
|
|
172
|
+
var _collabState2 = {
|
|
125
173
|
version: version,
|
|
126
174
|
unconfirmed: []
|
|
127
175
|
};
|
|
128
|
-
tr.setMeta('collab$',
|
|
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.
|
|
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": "^
|
|
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.
|
|
48
|
+
"@atlaskit/editor-common": "^112.16.0",
|
|
49
49
|
"react": "^18.2.0",
|
|
50
50
|
"react-dom": "^18.2.0"
|
|
51
51
|
},
|