Skip to content

Commit 86b74fe

Browse files
fix: replacement text in bubble (#2769)
* fix: replacement text in bubble * test(comments): harden replacement tracked-change test coverage (SD-2509) Add assertions for changeId, trackedChangeDisplayType, and deletedText null-guards on pure-insertion path. Add Playwright spec for the update-after-replace flow that triggered SD-2509. * refactor(comments): rename isDeletionInsertion to isReplacement (SD-2509) The old name read as "deletion then insertion" but actually means "both marks co-exist for the same change id, i.e. a replacement." Every call site had a comment re-explaining this. The rename makes the intent self-evident and retires those comments. --------- Co-authored-by: Caio Pizzol <caio@harbourshare.com>
1 parent 4ba8992 commit 86b74fe

3 files changed

Lines changed: 117 additions & 23 deletions

File tree

packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.js

Lines changed: 12 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -988,13 +988,13 @@ const normalizeFormatAttrsForCommentText = (attrs = {}, nodes) => {
988988
};
989989
};
990990

991-
const getTrackedChangeText = ({ nodes, mark, trackedChangeType, isDeletionInsertion }) => {
991+
const getTrackedChangeText = ({ nodes, mark, trackedChangeType, isReplacement }) => {
992992
let trackedChangeText = '';
993993
let deletionText = '';
994994
let trackedChangeDisplayType = null;
995995

996996
// Extract deletion text first
997-
if (trackedChangeType === TrackDeleteMarkName || isDeletionInsertion) {
997+
if (trackedChangeType === TrackDeleteMarkName || isReplacement) {
998998
deletionText = nodes.reduce((acc, node) => {
999999
const hasDeleteMark = node.marks.find((nodeMark) => nodeMark.type.name === TrackDeleteMarkName);
10001000
if (!hasDeleteMark) return acc;
@@ -1004,7 +1004,7 @@ const getTrackedChangeText = ({ nodes, mark, trackedChangeType, isDeletionInsert
10041004
}, '');
10051005
}
10061006

1007-
if (trackedChangeType === TrackInsertMarkName || isDeletionInsertion) {
1007+
if (trackedChangeType === TrackInsertMarkName || isReplacement) {
10081008
trackedChangeText = nodes.reduce((acc, node) => {
10091009
const hasInsertMark = node.marks.find((nodeMark) => nodeMark.type.name === TrackInsertMarkName);
10101010
if (!hasInsertMark) return acc;
@@ -1064,17 +1064,14 @@ const createOrUpdateTrackedChangeComment = ({
10641064
const { author, authorEmail, authorImage, date, importedAuthor } = attrs;
10651065
const id = attrs.id;
10661066

1067-
// Check metadata first - this should be set correctly by groupChanges() in createCommentForTrackChanges
1068-
// for both newly created and imported tracked changes
1069-
let isDeletionInsertion = !!(marks.insertedMark && marks.deletionMark);
1067+
let isReplacement = !!(marks.insertedMark && marks.deletionMark);
10701068

1071-
// Fallback: If metadata doesn't indicate replacement (e.g., edge cases during import),
1072-
// check the document state directly to detect replacements by finding both marks with same ID
1073-
// This ensures robustness even if groupChanges() misses a replacement or metadata isn't set
1074-
if (!isDeletionInsertion) {
1069+
// Fallback: check the document for both mark types under the same ID
1070+
// (covers edge cases where transaction meta only carries one mark)
1071+
if (!isReplacement) {
10751072
const hasInsertMark = trackedChangesWithId.some(({ mark }) => mark.type.name === TrackInsertMarkName);
10761073
const hasDeleteMark = trackedChangesWithId.some(({ mark }) => mark.type.name === TrackDeleteMarkName);
1077-
isDeletionInsertion = hasInsertMark && hasDeleteMark;
1074+
isReplacement = hasInsertMark && hasDeleteMark;
10781075
}
10791076

10801077
// Collect nodes from the tracked changes found
@@ -1097,10 +1094,8 @@ const createOrUpdateTrackedChangeComment = ({
10971094
});
10981095
});
10991096

1100-
// For replacements, we need both insertion nodes and deletion nodes
1101-
// When isDeletionInsertion is true, nodesWithMark should contain both types
11021097
let nodesToUse;
1103-
if (isDeletionInsertion) {
1098+
if (isReplacement) {
11041099
// For replacements, prefer nodes found in the document to avoid duplicating text
11051100
// when step.slice/deletionNodes include overlapping content.
11061101
const hasInsertNode = nodesWithMark.some((node) =>
@@ -1128,7 +1123,7 @@ const createOrUpdateTrackedChangeComment = ({
11281123
nodes: nodesToUse,
11291124
mark: trackedMark,
11301125
trackedChangeType,
1131-
isDeletionInsertion,
1126+
isReplacement,
11321127
deletionNodes,
11331128
});
11341129

@@ -1141,10 +1136,10 @@ const createOrUpdateTrackedChangeComment = ({
11411136
type: 'trackedChange',
11421137
documentId,
11431138
changeId: id,
1144-
trackedChangeType: isDeletionInsertion ? 'both' : trackedChangeType,
1139+
trackedChangeType: isReplacement ? 'both' : trackedChangeType,
11451140
trackedChangeText,
11461141
trackedChangeDisplayType,
1147-
deletedText: marks.deletionMark ? deletionText : null,
1142+
deletedText: isReplacement || marks.deletionMark ? deletionText : null,
11481143
author,
11491144
authorEmail,
11501145
...(authorImage && { authorImage }),

packages/super-editor/src/editors/v1/extensions/comment/comments-plugin.test.js

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1022,7 +1022,7 @@ describe('internal helper functions', () => {
10221022
nodes: insertionNodes,
10231023
mark: insertMark,
10241024
trackedChangeType: TrackInsertMarkName,
1025-
isDeletionInsertion: false,
1025+
isReplacement: false,
10261026
});
10271027
expect(insertionResult.trackedChangeText).toBe('Added');
10281028
expect(insertionResult.deletionText).toBe('');
@@ -1031,15 +1031,15 @@ describe('internal helper functions', () => {
10311031
nodes: deletionNodes,
10321032
mark: deleteMark,
10331033
trackedChangeType: TrackDeleteMarkName,
1034-
isDeletionInsertion: false,
1034+
isReplacement: false,
10351035
});
10361036
expect(deletionResult.deletionText).toBe('Removed');
10371037

10381038
const formatResult = getTrackedChangeText({
10391039
nodes: [schema.text('Format', [formatMark])],
10401040
mark: formatMark,
10411041
trackedChangeType: TrackFormatMarkName,
1042-
isDeletionInsertion: false,
1042+
isReplacement: false,
10431043
});
10441044
expect(formatResult.trackedChangeText).toBe('italic, removed bold');
10451045
expect(formatResult.trackedChangeDisplayType).toBeNull();
@@ -1053,7 +1053,7 @@ describe('internal helper functions', () => {
10531053
nodes: [schema.text('Format', [deltaFormatMark])],
10541054
mark: deltaFormatMark,
10551055
trackedChangeType: TrackFormatMarkName,
1056-
isDeletionInsertion: false,
1056+
isReplacement: false,
10571057
});
10581058
expect(deltaFormatResult.trackedChangeText).toContain('bold');
10591059
expect(deltaFormatResult.trackedChangeText).not.toContain('undefined');
@@ -1070,7 +1070,7 @@ describe('internal helper functions', () => {
10701070
nodes: [schema.text('website', [hyperlinkFormatMark, schema.marks.link.create({ href: 'https://example.com' })])],
10711071
mark: hyperlinkFormatMark,
10721072
trackedChangeType: TrackFormatMarkName,
1073-
isDeletionInsertion: false,
1073+
isReplacement: false,
10741074
});
10751075
expect(hyperlinkFormatResult).toMatchObject({
10761076
trackedChangeText: 'https://example.com',
@@ -1081,7 +1081,7 @@ describe('internal helper functions', () => {
10811081
nodes: [...insertionNodes, ...deletionNodes],
10821082
mark: insertMark,
10831083
trackedChangeType: TrackInsertMarkName,
1084-
isDeletionInsertion: true,
1084+
isReplacement: true,
10851085
});
10861086
expect(combinedResult.deletionText).toBe('Removed');
10871087
});
@@ -1124,6 +1124,49 @@ describe('internal helper functions', () => {
11241124
expect(payload?.deletedText).toBe('original');
11251125
});
11261126

1127+
it('preserves deletedText on replacement update when transaction meta only carries insertion mark', () => {
1128+
const schema = createCommentSchema();
1129+
const insertMark = schema.marks[TrackInsertMarkName].create({
1130+
id: 'replace-update-1',
1131+
author: 'Author',
1132+
authorEmail: 'author@example.com',
1133+
date: 'today',
1134+
});
1135+
const deleteMark = schema.marks[TrackDeleteMarkName].create({
1136+
id: 'replace-update-1',
1137+
author: 'Author',
1138+
authorEmail: 'author@example.com',
1139+
date: 'today',
1140+
});
1141+
1142+
const docInsertNode = schema.text('replacement', [insertMark]);
1143+
const docDeleteNode = schema.text('original', [deleteMark]);
1144+
const doc = schema.node('doc', null, [schema.node('paragraph', null, [docInsertNode, docDeleteNode])]);
1145+
const state = EditorState.create({ schema, doc });
1146+
1147+
// Simulate an update transaction where meta has only insertedMark, but the
1148+
// document still has both insert+delete marks under the same tracked-change id.
1149+
const payload = createOrUpdateTrackedChangeComment({
1150+
event: 'update',
1151+
marks: { insertedMark: insertMark, deletionMark: null, formatMark: null },
1152+
deletionNodes: [],
1153+
nodes: [schema.text('replacement', [insertMark])],
1154+
newEditorState: state,
1155+
documentId: 'doc-1',
1156+
trackedChangesForId: [
1157+
{ mark: insertMark, from: 1, to: doc.content.size },
1158+
{ mark: deleteMark, from: 1, to: doc.content.size },
1159+
],
1160+
});
1161+
1162+
expect(payload?.event).toBe(comments_module_events.UPDATE);
1163+
expect(payload?.trackedChangeType).toBe('both');
1164+
expect(payload?.trackedChangeText).toBe('replacement');
1165+
expect(payload?.deletedText).toBe('original');
1166+
expect(payload?.changeId).toBe('replace-update-1');
1167+
expect(payload?.trackedChangeDisplayType).toBeNull();
1168+
});
1169+
11271170
it('createOrUpdateTrackedChangeComment builds add and update payloads', () => {
11281171
const schema = createCommentSchema();
11291172
const insertMark = schema.marks[TrackInsertMarkName].create({
@@ -1152,9 +1195,11 @@ describe('internal helper functions', () => {
11521195
changeId: 'create-1',
11531196
trackedChangeText: 'Body',
11541197
});
1198+
expect(addPayload.deletedText).toBeNull();
11551199

11561200
const updatePayload = createOrUpdateTrackedChangeComment({ event: 'update', ...baseArgs });
11571201
expect(updatePayload.event).toBe(comments_module_events.UPDATE);
1202+
expect(updatePayload.deletedText).toBeNull();
11581203

11591204
const emptyState = EditorState.create({
11601205
schema,
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { test, expect } from '../../fixtures/superdoc.js';
2+
import { assertDocumentApiReady, listTrackChanges } from '../../helpers/document-api.js';
3+
4+
test.use({ config: { toolbar: 'full', comments: 'on', trackChanges: true } });
5+
6+
test('SD-2509 replacement bubble preserves deleted text after follow-up edits', async ({ superdoc }) => {
7+
await assertDocumentApiReady(superdoc.page);
8+
9+
// Type baseline text in editing mode
10+
await superdoc.type('original');
11+
await superdoc.waitForStable();
12+
13+
// Switch to suggesting mode and replace
14+
await superdoc.setDocumentMode('suggesting');
15+
await superdoc.waitForStable();
16+
17+
await superdoc.tripleClickLine(0);
18+
await superdoc.waitForStable();
19+
await superdoc.type('replacement');
20+
await superdoc.waitForStable();
21+
22+
// Wait for the tracked change to appear
23+
await expect
24+
.poll(async () => (await listTrackChanges(superdoc.page, { type: 'insert' })).total)
25+
.toBeGreaterThanOrEqual(1);
26+
27+
// The bubble should show both the deleted and inserted text
28+
const dialog = superdoc.page.locator('.comment-placeholder .comments-dialog', {
29+
has: superdoc.page.locator('.tracked-change-text'),
30+
});
31+
await expect(dialog).toBeVisible({ timeout: 10_000 });
32+
33+
const deletedText = dialog.locator('.tracked-change-text.is-deleted');
34+
const insertedText = dialog.locator('.tracked-change-text.is-inserted');
35+
36+
// Both sides of the replacement must be visible
37+
await expect(deletedText).toBeVisible();
38+
await expect(deletedText).toContainText('original');
39+
await expect(insertedText).toContainText('replacement');
40+
41+
// Type more inside the insertion to trigger update transactions with insert-only meta.
42+
// This is the SD-2509 bug scenario: subsequent keystrokes fire updates where only
43+
// insertedMark is in the transaction meta, but both marks still exist in the document.
44+
await superdoc.page.keyboard.press('End');
45+
await superdoc.page.keyboard.type(' extra');
46+
await superdoc.waitForStable();
47+
48+
// The deleted text must still be visible after the update
49+
await expect(deletedText).toBeVisible();
50+
await expect(deletedText).toContainText('original');
51+
await expect(insertedText).toContainText('replacement extra');
52+
53+
await superdoc.snapshot('sd-2509-replacement-update-preserves-deleted-text');
54+
});

0 commit comments

Comments
 (0)