@atlaskit/editor-plugin-synced-block 10.0.20 → 10.0.21

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,15 @@
1
1
  # @atlaskit/editor-plugin-synced-block
2
2
 
3
+ ## 10.0.21
4
+
5
+ ### Patch Changes
6
+
7
+ - [`845de90a5f39e`](https://bitbucket.org/atlassian/atlassian-frontend-monorepo/commits/845de90a5f39e) -
8
+ [ux] Fix source synced block deletion intermittently failing when the document changes (from your
9
+ own edits or a collaborator's) while the delete confirmation dialog is open. The delete is now
10
+ recomputed from the current document on confirm, instead of replaying a stale transaction captured
11
+ when the dialog opened. Gated behind `platform_editor_blocks_patch_4`.
12
+
3
13
  ## 10.0.20
4
14
 
5
15
  ### Patch Changes
@@ -171,8 +171,15 @@ var filterTransactionOnline = function filterTransactionOnline(_ref3) {
171
171
  });
172
172
  });
173
173
  if (bodiedSyncBlockRemoved.length > 0) {
174
- // eslint-disable-next-line no-param-reassign
175
- confirmationTransactionRef.current = tr;
174
+ if (!(0, _platformFeatureFlags.fg)('platform_editor_blocks_patch_4')) {
175
+ // Legacy stash-and-rebase path: store the original delete transaction so it
176
+ // can be rebased against intervening edits and replayed on confirm. When
177
+ // platform_editor_blocks_patch_4 is on we instead recompute the delete fresh
178
+ // from the live document on confirm (see handleBodiedSyncBlockRemoval), so
179
+ // there is no need to stash the transaction here. See EDITOR-7889.
180
+ // eslint-disable-next-line no-param-reassign
181
+ confirmationTransactionRef.current = tr;
182
+ }
176
183
  return (0, _handleBodiedSyncBlockRemoval.handleBodiedSyncBlockRemoval)(bodiedSyncBlockRemoved, syncBlockStore, api, confirmationTransactionRef, getDeleteReason(tr));
177
184
  }
178
185
  if (bodiedSyncBlockAdded.length > 0) {
@@ -887,13 +894,19 @@ var createPlugin = exports.createPlugin = function createPlugin(options, pmPlugi
887
894
  }
888
895
  });
889
896
  }
890
- trs.filter(function (tr) {
891
- return tr.docChanged;
892
- }).forEach(function (tr) {
893
- if (confirmationTransactionRef.current) {
894
- confirmationTransactionRef.current = (0, _editorSyncedBlockProvider.rebaseTransaction)(confirmationTransactionRef.current, tr, newState);
895
- }
896
- });
897
+
898
+ // Legacy stash-and-rebase path. When platform_editor_blocks_patch_4 is on the
899
+ // delete is recomputed fresh from the live document on confirm, so there is
900
+ // no stashed transaction to rebase here. See EDITOR-7889.
901
+ if (!(0, _platformFeatureFlags.fg)('platform_editor_blocks_patch_4')) {
902
+ trs.filter(function (tr) {
903
+ return tr.docChanged;
904
+ }).forEach(function (tr) {
905
+ if (confirmationTransactionRef.current) {
906
+ confirmationTransactionRef.current = (0, _editorSyncedBlockProvider.rebaseTransaction)(confirmationTransactionRef.current, tr, newState);
907
+ }
908
+ });
909
+ }
897
910
  var _iterator2 = _createForOfIteratorHelper(trs),
898
911
  _step2;
899
912
  try {
@@ -7,8 +7,10 @@ Object.defineProperty(exports, "__esModule", {
7
7
  exports.handleBodiedSyncBlockRemoval = void 0;
8
8
  var _defineProperty2 = _interopRequireDefault(require("@babel/runtime/helpers/defineProperty"));
9
9
  var _utils = require("@atlaskit/editor-common/utils");
10
+ var _platformFeatureFlags = require("@atlaskit/platform-feature-flags");
10
11
  var _types = require("../../types");
11
12
  var _main = require("../main");
13
+ var _recomputeDeleteTransaction = require("./recompute-delete-transaction");
12
14
  function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
13
15
  function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { (0, _defineProperty2.default)(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
14
16
  var onRetry = function onRetry(api, syncBlockStore) {
@@ -43,12 +45,45 @@ var handleBodiedSyncBlockRemoval = exports.handleBodiedSyncBlockRemoval = functi
43
45
  syncBlockStore.sourceManager.deleteSyncBlocksWithConfirmation(bodiedSyncBlockRemoved.map(function (node) {
44
46
  return node.attrs;
45
47
  }), deletionReason, function () {
46
- var _api$core2;
48
+ var _api$core3;
49
+ if ((0, _platformFeatureFlags.fg)('platform_editor_blocks_patch_4')) {
50
+ var _api$core2;
51
+ // Recompute the delete fresh from the live document instead of
52
+ // replaying a stashed-and-rebased transaction. The stash/rebase
53
+ // approach produced stale, schema-invalid transactions when the
54
+ // document changed shape (local edits or remote collab) while the
55
+ // confirmation modal was open — the dominant signature being
56
+ // "Invalid content for node bodiedSyncBlock: <>". See EDITOR-7889.
57
+ api === null || api === void 0 || (_api$core2 = api.core) === null || _api$core2 === void 0 || _api$core2.actions.execute(function (_ref2) {
58
+ var tr = _ref2.tr;
59
+ var recomputedTr = (0, _recomputeDeleteTransaction.recomputeDeleteTransaction)(tr, syncBlockStore.sourceManager.isSourceBlock, bodiedSyncBlockRemoved.map(function (node) {
60
+ return node.attrs;
61
+ }));
62
+
63
+ // The target node(s) no longer exist in the live document (e.g.
64
+ // a remote collaborator already removed them). There is nothing
65
+ // to delete locally, so return `null` to skip the dispatch
66
+ // entirely — dispatching the untouched transaction would be a
67
+ // no-op that still runs the plugin's filter/append hooks. The
68
+ // backend deletion has already been issued by the store manager.
69
+ if (!recomputedTr) {
70
+ return null;
71
+ }
72
+ recomputedTr.setMeta('isConfirmedSyncBlockDeletion', true);
73
+ if (!recomputedTr.getMeta(_utils.pmHistoryPluginKey)) {
74
+ // bodiedSyncBlock deletion is expected to be permanent (cannot undo)
75
+ // For a normal deletion (not triggered by undo), remove it from history so that it cannot be undone
76
+ recomputedTr.setMeta('addToHistory', false);
77
+ }
78
+ return recomputedTr;
79
+ });
80
+ return;
81
+ }
47
82
  var confirmationTransaction = confirmationTransactionRef.current;
48
83
  if (!confirmationTransaction) {
49
84
  return;
50
85
  }
51
- api === null || api === void 0 || (_api$core2 = api.core) === null || _api$core2 === void 0 || _api$core2.actions.execute(function () {
86
+ api === null || api === void 0 || (_api$core3 = api.core) === null || _api$core3 === void 0 || _api$core3.actions.execute(function () {
52
87
  var trToDispatch = confirmationTransaction.setMeta('isConfirmedSyncBlockDeletion', true);
53
88
  if (!trToDispatch.getMeta(_utils.pmHistoryPluginKey)) {
54
89
  // bodiedSyncBlock deletion is expected to be permanent (cannot undo)
@@ -58,9 +93,9 @@ var handleBodiedSyncBlockRemoval = exports.handleBodiedSyncBlockRemoval = functi
58
93
  return trToDispatch;
59
94
  });
60
95
  }, function (success) {
61
- var _api$core3;
62
- api === null || api === void 0 || (_api$core3 = api.core) === null || _api$core3 === void 0 || _api$core3.actions.execute(function (_ref2) {
63
- var tr = _ref2.tr;
96
+ var _api$core4;
97
+ api === null || api === void 0 || (_api$core4 = api.core) === null || _api$core4 === void 0 || _api$core4.actions.execute(function (_ref3) {
98
+ var tr = _ref3.tr;
64
99
  var newState;
65
100
  if (!success) {
66
101
  newState = {
@@ -0,0 +1,98 @@
1
+ "use strict";
2
+
3
+ Object.defineProperty(exports, "__esModule", {
4
+ value: true
5
+ });
6
+ exports.recomputeDeleteTransaction = void 0;
7
+ /**
8
+ * Whether `node` is the source block requested by `wanted`.
9
+ *
10
+ * Matching is done *per requested block* (rather than against global pools of
11
+ * every requested localId/resourceId) so a node cannot be matched by combining
12
+ * one block's `localId` with a different block's `resourceId`. We match on the
13
+ * requested block's `localId` first and only fall back to its `resourceId` when
14
+ * the `localId` has been regenerated (so it no longer matches the live node).
15
+ */
16
+ var matchesRequestedBlock = function matchesRequestedBlock(node, wanted) {
17
+ var _ref = node.attrs,
18
+ localId = _ref.localId,
19
+ resourceId = _ref.resourceId;
20
+ if (wanted.localId && localId && wanted.localId === localId) {
21
+ return true;
22
+ }
23
+ return Boolean(wanted.resourceId && resourceId && wanted.resourceId === resourceId);
24
+ };
25
+
26
+ /**
27
+ * Locate the live positions of the source `bodiedSyncBlock` nodes identified by
28
+ * `syncBlockIds`, matching first on `localId` and falling back to `resourceId`.
29
+ *
30
+ * This walks the *current* document (`tr.doc`) rather than relying on positions
31
+ * captured when the delete was first triggered, so it is robust to intervening
32
+ * local edits and remote collab changes that occur while the confirmation modal
33
+ * is open.
34
+ */
35
+ var findSourceBlocks = function findSourceBlocks(tr, isSourceBlock, syncBlockIds) {
36
+ var matches = [];
37
+ tr.doc.descendants(function (node, pos) {
38
+ if (!isSourceBlock(node)) {
39
+ // bodiedSyncBlock is always a top-level node, so there is no need to
40
+ // descend into other branches looking for one.
41
+ return false;
42
+ }
43
+ if (syncBlockIds.some(function (wanted) {
44
+ return matchesRequestedBlock(node, wanted);
45
+ })) {
46
+ matches.push({
47
+ node: node,
48
+ pos: pos
49
+ });
50
+ }
51
+
52
+ // Never recurse into a source block's body.
53
+ return false;
54
+ });
55
+ return matches;
56
+ };
57
+
58
+ /**
59
+ * Recompute a source `bodiedSyncBlock` delete from the live document.
60
+ *
61
+ * Instead of replaying a transaction that was stashed when the delete was first
62
+ * triggered (and then manually rebased against every intervening edit — the
63
+ * fragile pattern that produced "Invalid content for node bodiedSyncBlock: <>"
64
+ * and assorted position/open-depth errors), this finds the target node(s) by
65
+ * `localId`/`resourceId` in the current state and issues a fresh
66
+ * `tr.delete(pos, pos + nodeSize)` for each.
67
+ *
68
+ * Deletes are applied in reverse document order so earlier deletes do not
69
+ * invalidate the positions of later ones.
70
+ *
71
+ * Partial matches are handled gracefully: when only some of the requested
72
+ * blocks are still present (e.g. a remote collaborator removed the rest while
73
+ * the confirmation modal was open), the found ones are deleted and the missing
74
+ * ones are skipped. This is intentional — the backend deletion for every
75
+ * requested block has already been issued by the store manager, so the local
76
+ * transaction only needs to remove whatever is still in the live document.
77
+ *
78
+ * @returns the mutated transaction when at least one target node was found and
79
+ * deleted (this may be a partial delete if some targets were already gone),
80
+ * otherwise `undefined` when none of the targets exist any more — e.g. a remote
81
+ * collaborator already removed them all — so there is nothing to delete.
82
+ */
83
+ var recomputeDeleteTransaction = exports.recomputeDeleteTransaction = function recomputeDeleteTransaction(tr, isSourceBlock, syncBlockIds) {
84
+ var matches = findSourceBlocks(tr, isSourceBlock, syncBlockIds);
85
+ if (matches.length === 0) {
86
+ return undefined;
87
+ }
88
+
89
+ // Delete in reverse document order so positions remain valid across deletes.
90
+ matches.sort(function (a, b) {
91
+ return b.pos - a.pos;
92
+ }).forEach(function (_ref2) {
93
+ var node = _ref2.node,
94
+ pos = _ref2.pos;
95
+ tr.delete(pos, pos + node.nodeSize);
96
+ });
97
+ return tr;
98
+ };
@@ -147,8 +147,15 @@ const filterTransactionOnline = ({
147
147
  });
148
148
  });
149
149
  if (bodiedSyncBlockRemoved.length > 0) {
150
- // eslint-disable-next-line no-param-reassign
151
- confirmationTransactionRef.current = tr;
150
+ if (!fg('platform_editor_blocks_patch_4')) {
151
+ // Legacy stash-and-rebase path: store the original delete transaction so it
152
+ // can be rebased against intervening edits and replayed on confirm. When
153
+ // platform_editor_blocks_patch_4 is on we instead recompute the delete fresh
154
+ // from the live document on confirm (see handleBodiedSyncBlockRemoval), so
155
+ // there is no need to stash the transaction here. See EDITOR-7889.
156
+ // eslint-disable-next-line no-param-reassign
157
+ confirmationTransactionRef.current = tr;
158
+ }
152
159
  return handleBodiedSyncBlockRemoval(bodiedSyncBlockRemoved, syncBlockStore, api, confirmationTransactionRef, getDeleteReason(tr));
153
160
  }
154
161
  if (bodiedSyncBlockAdded.length > 0) {
@@ -844,11 +851,17 @@ export const createPlugin = (options, pmPluginFactoryParams, syncBlockStore, api
844
851
  }
845
852
  });
846
853
  }
847
- trs.filter(tr => tr.docChanged).forEach(tr => {
848
- if (confirmationTransactionRef.current) {
849
- confirmationTransactionRef.current = rebaseTransaction(confirmationTransactionRef.current, tr, newState);
850
- }
851
- });
854
+
855
+ // Legacy stash-and-rebase path. When platform_editor_blocks_patch_4 is on the
856
+ // delete is recomputed fresh from the live document on confirm, so there is
857
+ // no stashed transaction to rebase here. See EDITOR-7889.
858
+ if (!fg('platform_editor_blocks_patch_4')) {
859
+ trs.filter(tr => tr.docChanged).forEach(tr => {
860
+ if (confirmationTransactionRef.current) {
861
+ confirmationTransactionRef.current = rebaseTransaction(confirmationTransactionRef.current, tr, newState);
862
+ }
863
+ });
864
+ }
852
865
  for (const tr of trs) {
853
866
  if (tr.getMeta(pmHistoryPluginKey)) {
854
867
  const {
@@ -1,6 +1,8 @@
1
1
  import { pmHistoryPluginKey } from '@atlaskit/editor-common/utils';
2
+ import { fg } from '@atlaskit/platform-feature-flags';
2
3
  import { FLAG_ID } from '../../types';
3
4
  import { syncedBlockPluginKey } from '../main';
5
+ import { recomputeDeleteTransaction } from './recompute-delete-transaction';
4
6
  const onRetry = (api, syncBlockStore) => () => {
5
7
  var _api$core;
6
8
  api === null || api === void 0 ? void 0 : (_api$core = api.core) === null || _api$core === void 0 ? void 0 : _api$core.actions.execute(({
@@ -28,12 +30,44 @@ export const handleBodiedSyncBlockRemoval = (bodiedSyncBlockRemoved, syncBlockSt
28
30
  // See editor-common/src/sync-block/sync-block-store-manager.ts for how we handle user confirmation and
29
31
  // proceed with deletion.
30
32
  syncBlockStore.sourceManager.deleteSyncBlocksWithConfirmation(bodiedSyncBlockRemoved.map(node => node.attrs), deletionReason, () => {
31
- var _api$core2;
33
+ var _api$core3;
34
+ if (fg('platform_editor_blocks_patch_4')) {
35
+ var _api$core2;
36
+ // Recompute the delete fresh from the live document instead of
37
+ // replaying a stashed-and-rebased transaction. The stash/rebase
38
+ // approach produced stale, schema-invalid transactions when the
39
+ // document changed shape (local edits or remote collab) while the
40
+ // confirmation modal was open — the dominant signature being
41
+ // "Invalid content for node bodiedSyncBlock: <>". See EDITOR-7889.
42
+ api === null || api === void 0 ? void 0 : (_api$core2 = api.core) === null || _api$core2 === void 0 ? void 0 : _api$core2.actions.execute(({
43
+ tr
44
+ }) => {
45
+ const recomputedTr = recomputeDeleteTransaction(tr, syncBlockStore.sourceManager.isSourceBlock, bodiedSyncBlockRemoved.map(node => node.attrs));
46
+
47
+ // The target node(s) no longer exist in the live document (e.g.
48
+ // a remote collaborator already removed them). There is nothing
49
+ // to delete locally, so return `null` to skip the dispatch
50
+ // entirely — dispatching the untouched transaction would be a
51
+ // no-op that still runs the plugin's filter/append hooks. The
52
+ // backend deletion has already been issued by the store manager.
53
+ if (!recomputedTr) {
54
+ return null;
55
+ }
56
+ recomputedTr.setMeta('isConfirmedSyncBlockDeletion', true);
57
+ if (!recomputedTr.getMeta(pmHistoryPluginKey)) {
58
+ // bodiedSyncBlock deletion is expected to be permanent (cannot undo)
59
+ // For a normal deletion (not triggered by undo), remove it from history so that it cannot be undone
60
+ recomputedTr.setMeta('addToHistory', false);
61
+ }
62
+ return recomputedTr;
63
+ });
64
+ return;
65
+ }
32
66
  const confirmationTransaction = confirmationTransactionRef.current;
33
67
  if (!confirmationTransaction) {
34
68
  return;
35
69
  }
36
- api === null || api === void 0 ? void 0 : (_api$core2 = api.core) === null || _api$core2 === void 0 ? void 0 : _api$core2.actions.execute(() => {
70
+ api === null || api === void 0 ? void 0 : (_api$core3 = api.core) === null || _api$core3 === void 0 ? void 0 : _api$core3.actions.execute(() => {
37
71
  const trToDispatch = confirmationTransaction.setMeta('isConfirmedSyncBlockDeletion', true);
38
72
  if (!trToDispatch.getMeta(pmHistoryPluginKey)) {
39
73
  // bodiedSyncBlock deletion is expected to be permanent (cannot undo)
@@ -43,8 +77,8 @@ export const handleBodiedSyncBlockRemoval = (bodiedSyncBlockRemoved, syncBlockSt
43
77
  return trToDispatch;
44
78
  });
45
79
  }, success => {
46
- var _api$core3;
47
- api === null || api === void 0 ? void 0 : (_api$core3 = api.core) === null || _api$core3 === void 0 ? void 0 : _api$core3.actions.execute(({
80
+ var _api$core4;
81
+ api === null || api === void 0 ? void 0 : (_api$core4 = api.core) === null || _api$core4 === void 0 ? void 0 : _api$core4.actions.execute(({
48
82
  tr
49
83
  }) => {
50
84
  let newState;
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Whether `node` is the source block requested by `wanted`.
3
+ *
4
+ * Matching is done *per requested block* (rather than against global pools of
5
+ * every requested localId/resourceId) so a node cannot be matched by combining
6
+ * one block's `localId` with a different block's `resourceId`. We match on the
7
+ * requested block's `localId` first and only fall back to its `resourceId` when
8
+ * the `localId` has been regenerated (so it no longer matches the live node).
9
+ */
10
+ const matchesRequestedBlock = (node, wanted) => {
11
+ const {
12
+ localId,
13
+ resourceId
14
+ } = node.attrs;
15
+ if (wanted.localId && localId && wanted.localId === localId) {
16
+ return true;
17
+ }
18
+ return Boolean(wanted.resourceId && resourceId && wanted.resourceId === resourceId);
19
+ };
20
+
21
+ /**
22
+ * Locate the live positions of the source `bodiedSyncBlock` nodes identified by
23
+ * `syncBlockIds`, matching first on `localId` and falling back to `resourceId`.
24
+ *
25
+ * This walks the *current* document (`tr.doc`) rather than relying on positions
26
+ * captured when the delete was first triggered, so it is robust to intervening
27
+ * local edits and remote collab changes that occur while the confirmation modal
28
+ * is open.
29
+ */
30
+ const findSourceBlocks = (tr, isSourceBlock, syncBlockIds) => {
31
+ const matches = [];
32
+ tr.doc.descendants((node, pos) => {
33
+ if (!isSourceBlock(node)) {
34
+ // bodiedSyncBlock is always a top-level node, so there is no need to
35
+ // descend into other branches looking for one.
36
+ return false;
37
+ }
38
+ if (syncBlockIds.some(wanted => matchesRequestedBlock(node, wanted))) {
39
+ matches.push({
40
+ node,
41
+ pos
42
+ });
43
+ }
44
+
45
+ // Never recurse into a source block's body.
46
+ return false;
47
+ });
48
+ return matches;
49
+ };
50
+
51
+ /**
52
+ * Recompute a source `bodiedSyncBlock` delete from the live document.
53
+ *
54
+ * Instead of replaying a transaction that was stashed when the delete was first
55
+ * triggered (and then manually rebased against every intervening edit — the
56
+ * fragile pattern that produced "Invalid content for node bodiedSyncBlock: <>"
57
+ * and assorted position/open-depth errors), this finds the target node(s) by
58
+ * `localId`/`resourceId` in the current state and issues a fresh
59
+ * `tr.delete(pos, pos + nodeSize)` for each.
60
+ *
61
+ * Deletes are applied in reverse document order so earlier deletes do not
62
+ * invalidate the positions of later ones.
63
+ *
64
+ * Partial matches are handled gracefully: when only some of the requested
65
+ * blocks are still present (e.g. a remote collaborator removed the rest while
66
+ * the confirmation modal was open), the found ones are deleted and the missing
67
+ * ones are skipped. This is intentional — the backend deletion for every
68
+ * requested block has already been issued by the store manager, so the local
69
+ * transaction only needs to remove whatever is still in the live document.
70
+ *
71
+ * @returns the mutated transaction when at least one target node was found and
72
+ * deleted (this may be a partial delete if some targets were already gone),
73
+ * otherwise `undefined` when none of the targets exist any more — e.g. a remote
74
+ * collaborator already removed them all — so there is nothing to delete.
75
+ */
76
+ export const recomputeDeleteTransaction = (tr, isSourceBlock, syncBlockIds) => {
77
+ const matches = findSourceBlocks(tr, isSourceBlock, syncBlockIds);
78
+ if (matches.length === 0) {
79
+ return undefined;
80
+ }
81
+
82
+ // Delete in reverse document order so positions remain valid across deletes.
83
+ matches.sort((a, b) => b.pos - a.pos).forEach(({
84
+ node,
85
+ pos
86
+ }) => {
87
+ tr.delete(pos, pos + node.nodeSize);
88
+ });
89
+ return tr;
90
+ };
@@ -164,8 +164,15 @@ var filterTransactionOnline = function filterTransactionOnline(_ref3) {
164
164
  });
165
165
  });
166
166
  if (bodiedSyncBlockRemoved.length > 0) {
167
- // eslint-disable-next-line no-param-reassign
168
- confirmationTransactionRef.current = tr;
167
+ if (!fg('platform_editor_blocks_patch_4')) {
168
+ // Legacy stash-and-rebase path: store the original delete transaction so it
169
+ // can be rebased against intervening edits and replayed on confirm. When
170
+ // platform_editor_blocks_patch_4 is on we instead recompute the delete fresh
171
+ // from the live document on confirm (see handleBodiedSyncBlockRemoval), so
172
+ // there is no need to stash the transaction here. See EDITOR-7889.
173
+ // eslint-disable-next-line no-param-reassign
174
+ confirmationTransactionRef.current = tr;
175
+ }
169
176
  return handleBodiedSyncBlockRemoval(bodiedSyncBlockRemoved, syncBlockStore, api, confirmationTransactionRef, getDeleteReason(tr));
170
177
  }
171
178
  if (bodiedSyncBlockAdded.length > 0) {
@@ -880,13 +887,19 @@ export var createPlugin = function createPlugin(options, pmPluginFactoryParams,
880
887
  }
881
888
  });
882
889
  }
883
- trs.filter(function (tr) {
884
- return tr.docChanged;
885
- }).forEach(function (tr) {
886
- if (confirmationTransactionRef.current) {
887
- confirmationTransactionRef.current = rebaseTransaction(confirmationTransactionRef.current, tr, newState);
888
- }
889
- });
890
+
891
+ // Legacy stash-and-rebase path. When platform_editor_blocks_patch_4 is on the
892
+ // delete is recomputed fresh from the live document on confirm, so there is
893
+ // no stashed transaction to rebase here. See EDITOR-7889.
894
+ if (!fg('platform_editor_blocks_patch_4')) {
895
+ trs.filter(function (tr) {
896
+ return tr.docChanged;
897
+ }).forEach(function (tr) {
898
+ if (confirmationTransactionRef.current) {
899
+ confirmationTransactionRef.current = rebaseTransaction(confirmationTransactionRef.current, tr, newState);
900
+ }
901
+ });
902
+ }
890
903
  var _iterator2 = _createForOfIteratorHelper(trs),
891
904
  _step2;
892
905
  try {
@@ -2,8 +2,10 @@ import _defineProperty from "@babel/runtime/helpers/defineProperty";
2
2
  function ownKeys(e, r) { var t = Object.keys(e); if (Object.getOwnPropertySymbols) { var o = Object.getOwnPropertySymbols(e); r && (o = o.filter(function (r) { return Object.getOwnPropertyDescriptor(e, r).enumerable; })), t.push.apply(t, o); } return t; }
3
3
  function _objectSpread(e) { for (var r = 1; r < arguments.length; r++) { var t = null != arguments[r] ? arguments[r] : {}; r % 2 ? ownKeys(Object(t), !0).forEach(function (r) { _defineProperty(e, r, t[r]); }) : Object.getOwnPropertyDescriptors ? Object.defineProperties(e, Object.getOwnPropertyDescriptors(t)) : ownKeys(Object(t)).forEach(function (r) { Object.defineProperty(e, r, Object.getOwnPropertyDescriptor(t, r)); }); } return e; }
4
4
  import { pmHistoryPluginKey } from '@atlaskit/editor-common/utils';
5
+ import { fg } from '@atlaskit/platform-feature-flags';
5
6
  import { FLAG_ID } from '../../types';
6
7
  import { syncedBlockPluginKey } from '../main';
8
+ import { recomputeDeleteTransaction } from './recompute-delete-transaction';
7
9
  var onRetry = function onRetry(api, syncBlockStore) {
8
10
  return function () {
9
11
  var _api$core;
@@ -36,12 +38,45 @@ export var handleBodiedSyncBlockRemoval = function handleBodiedSyncBlockRemoval(
36
38
  syncBlockStore.sourceManager.deleteSyncBlocksWithConfirmation(bodiedSyncBlockRemoved.map(function (node) {
37
39
  return node.attrs;
38
40
  }), deletionReason, function () {
39
- var _api$core2;
41
+ var _api$core3;
42
+ if (fg('platform_editor_blocks_patch_4')) {
43
+ var _api$core2;
44
+ // Recompute the delete fresh from the live document instead of
45
+ // replaying a stashed-and-rebased transaction. The stash/rebase
46
+ // approach produced stale, schema-invalid transactions when the
47
+ // document changed shape (local edits or remote collab) while the
48
+ // confirmation modal was open — the dominant signature being
49
+ // "Invalid content for node bodiedSyncBlock: <>". See EDITOR-7889.
50
+ api === null || api === void 0 || (_api$core2 = api.core) === null || _api$core2 === void 0 || _api$core2.actions.execute(function (_ref2) {
51
+ var tr = _ref2.tr;
52
+ var recomputedTr = recomputeDeleteTransaction(tr, syncBlockStore.sourceManager.isSourceBlock, bodiedSyncBlockRemoved.map(function (node) {
53
+ return node.attrs;
54
+ }));
55
+
56
+ // The target node(s) no longer exist in the live document (e.g.
57
+ // a remote collaborator already removed them). There is nothing
58
+ // to delete locally, so return `null` to skip the dispatch
59
+ // entirely — dispatching the untouched transaction would be a
60
+ // no-op that still runs the plugin's filter/append hooks. The
61
+ // backend deletion has already been issued by the store manager.
62
+ if (!recomputedTr) {
63
+ return null;
64
+ }
65
+ recomputedTr.setMeta('isConfirmedSyncBlockDeletion', true);
66
+ if (!recomputedTr.getMeta(pmHistoryPluginKey)) {
67
+ // bodiedSyncBlock deletion is expected to be permanent (cannot undo)
68
+ // For a normal deletion (not triggered by undo), remove it from history so that it cannot be undone
69
+ recomputedTr.setMeta('addToHistory', false);
70
+ }
71
+ return recomputedTr;
72
+ });
73
+ return;
74
+ }
40
75
  var confirmationTransaction = confirmationTransactionRef.current;
41
76
  if (!confirmationTransaction) {
42
77
  return;
43
78
  }
44
- api === null || api === void 0 || (_api$core2 = api.core) === null || _api$core2 === void 0 || _api$core2.actions.execute(function () {
79
+ api === null || api === void 0 || (_api$core3 = api.core) === null || _api$core3 === void 0 || _api$core3.actions.execute(function () {
45
80
  var trToDispatch = confirmationTransaction.setMeta('isConfirmedSyncBlockDeletion', true);
46
81
  if (!trToDispatch.getMeta(pmHistoryPluginKey)) {
47
82
  // bodiedSyncBlock deletion is expected to be permanent (cannot undo)
@@ -51,9 +86,9 @@ export var handleBodiedSyncBlockRemoval = function handleBodiedSyncBlockRemoval(
51
86
  return trToDispatch;
52
87
  });
53
88
  }, function (success) {
54
- var _api$core3;
55
- api === null || api === void 0 || (_api$core3 = api.core) === null || _api$core3 === void 0 || _api$core3.actions.execute(function (_ref2) {
56
- var tr = _ref2.tr;
89
+ var _api$core4;
90
+ api === null || api === void 0 || (_api$core4 = api.core) === null || _api$core4 === void 0 || _api$core4.actions.execute(function (_ref3) {
91
+ var tr = _ref3.tr;
57
92
  var newState;
58
93
  if (!success) {
59
94
  newState = {
@@ -0,0 +1,92 @@
1
+ /**
2
+ * Whether `node` is the source block requested by `wanted`.
3
+ *
4
+ * Matching is done *per requested block* (rather than against global pools of
5
+ * every requested localId/resourceId) so a node cannot be matched by combining
6
+ * one block's `localId` with a different block's `resourceId`. We match on the
7
+ * requested block's `localId` first and only fall back to its `resourceId` when
8
+ * the `localId` has been regenerated (so it no longer matches the live node).
9
+ */
10
+ var matchesRequestedBlock = function matchesRequestedBlock(node, wanted) {
11
+ var _ref = node.attrs,
12
+ localId = _ref.localId,
13
+ resourceId = _ref.resourceId;
14
+ if (wanted.localId && localId && wanted.localId === localId) {
15
+ return true;
16
+ }
17
+ return Boolean(wanted.resourceId && resourceId && wanted.resourceId === resourceId);
18
+ };
19
+
20
+ /**
21
+ * Locate the live positions of the source `bodiedSyncBlock` nodes identified by
22
+ * `syncBlockIds`, matching first on `localId` and falling back to `resourceId`.
23
+ *
24
+ * This walks the *current* document (`tr.doc`) rather than relying on positions
25
+ * captured when the delete was first triggered, so it is robust to intervening
26
+ * local edits and remote collab changes that occur while the confirmation modal
27
+ * is open.
28
+ */
29
+ var findSourceBlocks = function findSourceBlocks(tr, isSourceBlock, syncBlockIds) {
30
+ var matches = [];
31
+ tr.doc.descendants(function (node, pos) {
32
+ if (!isSourceBlock(node)) {
33
+ // bodiedSyncBlock is always a top-level node, so there is no need to
34
+ // descend into other branches looking for one.
35
+ return false;
36
+ }
37
+ if (syncBlockIds.some(function (wanted) {
38
+ return matchesRequestedBlock(node, wanted);
39
+ })) {
40
+ matches.push({
41
+ node: node,
42
+ pos: pos
43
+ });
44
+ }
45
+
46
+ // Never recurse into a source block's body.
47
+ return false;
48
+ });
49
+ return matches;
50
+ };
51
+
52
+ /**
53
+ * Recompute a source `bodiedSyncBlock` delete from the live document.
54
+ *
55
+ * Instead of replaying a transaction that was stashed when the delete was first
56
+ * triggered (and then manually rebased against every intervening edit — the
57
+ * fragile pattern that produced "Invalid content for node bodiedSyncBlock: <>"
58
+ * and assorted position/open-depth errors), this finds the target node(s) by
59
+ * `localId`/`resourceId` in the current state and issues a fresh
60
+ * `tr.delete(pos, pos + nodeSize)` for each.
61
+ *
62
+ * Deletes are applied in reverse document order so earlier deletes do not
63
+ * invalidate the positions of later ones.
64
+ *
65
+ * Partial matches are handled gracefully: when only some of the requested
66
+ * blocks are still present (e.g. a remote collaborator removed the rest while
67
+ * the confirmation modal was open), the found ones are deleted and the missing
68
+ * ones are skipped. This is intentional — the backend deletion for every
69
+ * requested block has already been issued by the store manager, so the local
70
+ * transaction only needs to remove whatever is still in the live document.
71
+ *
72
+ * @returns the mutated transaction when at least one target node was found and
73
+ * deleted (this may be a partial delete if some targets were already gone),
74
+ * otherwise `undefined` when none of the targets exist any more — e.g. a remote
75
+ * collaborator already removed them all — so there is nothing to delete.
76
+ */
77
+ export var recomputeDeleteTransaction = function recomputeDeleteTransaction(tr, isSourceBlock, syncBlockIds) {
78
+ var matches = findSourceBlocks(tr, isSourceBlock, syncBlockIds);
79
+ if (matches.length === 0) {
80
+ return undefined;
81
+ }
82
+
83
+ // Delete in reverse document order so positions remain valid across deletes.
84
+ matches.sort(function (a, b) {
85
+ return b.pos - a.pos;
86
+ }).forEach(function (_ref2) {
87
+ var node = _ref2.node,
88
+ pos = _ref2.pos;
89
+ tr.delete(pos, pos + node.nodeSize);
90
+ });
91
+ return tr;
92
+ };
@@ -0,0 +1,29 @@
1
+ import type { Node as PMNode } from '@atlaskit/editor-prosemirror/model';
2
+ import type { Transaction } from '@atlaskit/editor-prosemirror/state';
3
+ import type { SyncBlockAttrs } from '../../types';
4
+ /**
5
+ * Recompute a source `bodiedSyncBlock` delete from the live document.
6
+ *
7
+ * Instead of replaying a transaction that was stashed when the delete was first
8
+ * triggered (and then manually rebased against every intervening edit — the
9
+ * fragile pattern that produced "Invalid content for node bodiedSyncBlock: <>"
10
+ * and assorted position/open-depth errors), this finds the target node(s) by
11
+ * `localId`/`resourceId` in the current state and issues a fresh
12
+ * `tr.delete(pos, pos + nodeSize)` for each.
13
+ *
14
+ * Deletes are applied in reverse document order so earlier deletes do not
15
+ * invalidate the positions of later ones.
16
+ *
17
+ * Partial matches are handled gracefully: when only some of the requested
18
+ * blocks are still present (e.g. a remote collaborator removed the rest while
19
+ * the confirmation modal was open), the found ones are deleted and the missing
20
+ * ones are skipped. This is intentional — the backend deletion for every
21
+ * requested block has already been issued by the store manager, so the local
22
+ * transaction only needs to remove whatever is still in the live document.
23
+ *
24
+ * @returns the mutated transaction when at least one target node was found and
25
+ * deleted (this may be a partial delete if some targets were already gone),
26
+ * otherwise `undefined` when none of the targets exist any more — e.g. a remote
27
+ * collaborator already removed them all — so there is nothing to delete.
28
+ */
29
+ export declare const recomputeDeleteTransaction: (tr: Transaction, isSourceBlock: (node: PMNode) => boolean, syncBlockIds: SyncBlockAttrs[]) => Transaction | undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@atlaskit/editor-plugin-synced-block",
3
- "version": "10.0.20",
3
+ "version": "10.0.21",
4
4
  "description": "SyncedBlock plugin for @atlaskit/editor-core",
5
5
  "author": "Atlassian Pty Ltd",
6
6
  "license": "Apache-2.0",
@@ -108,6 +108,9 @@
108
108
  "platform_editor_blocks_patch_2": {
109
109
  "type": "boolean"
110
110
  },
111
+ "platform_editor_blocks_patch_4": {
112
+ "type": "boolean"
113
+ },
111
114
  "platform_synced_block_patch_13": {
112
115
  "type": "boolean"
113
116
  },