@agent-native/toolkit 0.19.3 → 0.19.4-nightly-20260903213743
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/dist/editor/surgical-apply.d.ts +26 -0
- package/dist/editor/surgical-apply.d.ts.map +1 -1
- package/dist/editor/surgical-apply.js +142 -0
- package/dist/editor/surgical-apply.js.map +1 -1
- package/dist/editor/surgical-apply.spec.js +100 -1
- package/dist/editor/surgical-apply.spec.js.map +1 -1
- package/dist/editor/useCollabReconcile.concurrent.spec.js +94 -4
- package/dist/editor/useCollabReconcile.concurrent.spec.js.map +1 -1
- package/dist/editor/useCollabReconcile.d.ts +11 -1
- package/dist/editor/useCollabReconcile.d.ts.map +1 -1
- package/dist/editor/useCollabReconcile.js +78 -2
- package/dist/editor/useCollabReconcile.js.map +1 -1
- package/package.json +1 -1
- package/src/editor/surgical-apply.spec.ts +111 -0
- package/src/editor/surgical-apply.ts +191 -0
- package/src/editor/useCollabReconcile.concurrent.spec.ts +129 -3
- package/src/editor/useCollabReconcile.ts +101 -0
|
@@ -41,6 +41,197 @@ export interface TopLevelDiff {
|
|
|
41
41
|
toPos: number;
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
export interface TopLevelHunk {
|
|
45
|
+
baseFromIndex: number;
|
|
46
|
+
baseToIndex: number;
|
|
47
|
+
changedFromIndex: number;
|
|
48
|
+
changedToIndex: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export type BaseAwareReconcileResult =
|
|
52
|
+
| { status: "noop" }
|
|
53
|
+
| { status: "applied"; mergedDoc: ProseMirrorNode }
|
|
54
|
+
| { status: "conflict"; localDraft: ProseMirrorNode }
|
|
55
|
+
| { status: "failed"; reason: "schema" | "ambiguous" | "transaction" };
|
|
56
|
+
|
|
57
|
+
function nodesEqual(left: ProseMirrorNode, right: ProseMirrorNode): boolean {
|
|
58
|
+
return left.eq(right);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Return every changed top-level run in base coordinates. The bounded LCS is
|
|
63
|
+
* intentionally conservative: large or duplicate-heavy ambiguous documents
|
|
64
|
+
* fail closed instead of guessing which identical block an edit targeted.
|
|
65
|
+
*/
|
|
66
|
+
export function diffTopLevelHunks(
|
|
67
|
+
baseDoc: ProseMirrorNode,
|
|
68
|
+
changedDoc: ProseMirrorNode,
|
|
69
|
+
): TopLevelHunk[] | null {
|
|
70
|
+
const base = Array.from({ length: baseDoc.childCount }, (_, i) =>
|
|
71
|
+
baseDoc.child(i),
|
|
72
|
+
);
|
|
73
|
+
const changed = Array.from({ length: changedDoc.childCount }, (_, i) =>
|
|
74
|
+
changedDoc.child(i),
|
|
75
|
+
);
|
|
76
|
+
if (base.length * changed.length > 250_000) return null;
|
|
77
|
+
|
|
78
|
+
const width = changed.length + 1;
|
|
79
|
+
const lcs = new Uint32Array((base.length + 1) * width);
|
|
80
|
+
for (let i = base.length - 1; i >= 0; i--) {
|
|
81
|
+
for (let j = changed.length - 1; j >= 0; j--) {
|
|
82
|
+
lcs[i * width + j] = nodesEqual(base[i], changed[j])
|
|
83
|
+
? 1 + lcs[(i + 1) * width + j + 1]
|
|
84
|
+
: Math.max(lcs[(i + 1) * width + j], lcs[i * width + j + 1]);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const matches: Array<[number, number]> = [];
|
|
89
|
+
let i = 0;
|
|
90
|
+
let j = 0;
|
|
91
|
+
while (i < base.length && j < changed.length) {
|
|
92
|
+
if (nodesEqual(base[i], changed[j])) {
|
|
93
|
+
matches.push([i++, j++]);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
const skipBase = lcs[(i + 1) * width + j];
|
|
97
|
+
const skipChanged = lcs[i * width + j + 1];
|
|
98
|
+
if (skipBase === skipChanged) {
|
|
99
|
+
const insertion =
|
|
100
|
+
j + 1 < changed.length && nodesEqual(base[i], changed[j + 1]);
|
|
101
|
+
const deletion =
|
|
102
|
+
i + 1 < base.length && nodesEqual(base[i + 1], changed[j]);
|
|
103
|
+
if (insertion !== deletion) {
|
|
104
|
+
if (insertion) j++;
|
|
105
|
+
else i++;
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
// A plain substitution leaves the same suffix LCS after advancing both
|
|
109
|
+
// sides. Treat that as one changed run; only competing cross-matches are
|
|
110
|
+
// ambiguous enough to fail closed.
|
|
111
|
+
const skipBoth = lcs[(i + 1) * width + j + 1];
|
|
112
|
+
if (skipBoth === skipBase) {
|
|
113
|
+
i++;
|
|
114
|
+
j++;
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
119
|
+
if (skipBase > skipChanged) i++;
|
|
120
|
+
else j++;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
const hunks: TopLevelHunk[] = [];
|
|
124
|
+
let baseCursor = 0;
|
|
125
|
+
let changedCursor = 0;
|
|
126
|
+
for (const [baseMatch, changedMatch] of [
|
|
127
|
+
...matches,
|
|
128
|
+
[base.length, changed.length] as [number, number],
|
|
129
|
+
]) {
|
|
130
|
+
if (baseCursor !== baseMatch || changedCursor !== changedMatch) {
|
|
131
|
+
hunks.push({
|
|
132
|
+
baseFromIndex: baseCursor,
|
|
133
|
+
baseToIndex: baseMatch,
|
|
134
|
+
changedFromIndex: changedCursor,
|
|
135
|
+
changedToIndex: changedMatch,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
baseCursor = baseMatch + 1;
|
|
139
|
+
changedCursor = changedMatch + 1;
|
|
140
|
+
}
|
|
141
|
+
return hunks;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function hunksOverlap(left: TopLevelHunk, right: TopLevelHunk): boolean {
|
|
145
|
+
const leftInsert = left.baseFromIndex === left.baseToIndex;
|
|
146
|
+
const rightInsert = right.baseFromIndex === right.baseToIndex;
|
|
147
|
+
if (leftInsert && rightInsert) {
|
|
148
|
+
return left.baseFromIndex === right.baseFromIndex;
|
|
149
|
+
}
|
|
150
|
+
if (leftInsert) {
|
|
151
|
+
return (
|
|
152
|
+
left.baseFromIndex >= right.baseFromIndex &&
|
|
153
|
+
left.baseFromIndex <= right.baseToIndex
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
if (rightInsert) {
|
|
157
|
+
return (
|
|
158
|
+
right.baseFromIndex >= left.baseFromIndex &&
|
|
159
|
+
right.baseFromIndex <= left.baseToIndex
|
|
160
|
+
);
|
|
161
|
+
}
|
|
162
|
+
return (
|
|
163
|
+
left.baseFromIndex < right.baseToIndex &&
|
|
164
|
+
right.baseFromIndex < left.baseToIndex
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function mappedLocalIndex(
|
|
169
|
+
baseIndex: number,
|
|
170
|
+
localHunks: readonly TopLevelHunk[],
|
|
171
|
+
): number {
|
|
172
|
+
let mapped = baseIndex;
|
|
173
|
+
for (const hunk of localHunks) {
|
|
174
|
+
if (hunk.baseToIndex > baseIndex) break;
|
|
175
|
+
mapped +=
|
|
176
|
+
hunk.changedToIndex -
|
|
177
|
+
hunk.changedFromIndex -
|
|
178
|
+
(hunk.baseToIndex - hunk.baseFromIndex);
|
|
179
|
+
}
|
|
180
|
+
return mapped;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** Apply authoritative server changes onto a locally edited document. */
|
|
184
|
+
export function reconcileDocAgainstBase(
|
|
185
|
+
editor: Editor,
|
|
186
|
+
baseDoc: ProseMirrorNode,
|
|
187
|
+
serverDoc: ProseMirrorNode,
|
|
188
|
+
): BaseAwareReconcileResult {
|
|
189
|
+
const liveDoc = editor.state.doc;
|
|
190
|
+
if (
|
|
191
|
+
baseDoc.type.schema !== editor.schema ||
|
|
192
|
+
serverDoc.type.schema !== editor.schema ||
|
|
193
|
+
baseDoc.type !== liveDoc.type ||
|
|
194
|
+
serverDoc.type !== liveDoc.type
|
|
195
|
+
) {
|
|
196
|
+
return { status: "failed", reason: "schema" };
|
|
197
|
+
}
|
|
198
|
+
const localHunks = diffTopLevelHunks(baseDoc, liveDoc);
|
|
199
|
+
const serverHunks = diffTopLevelHunks(baseDoc, serverDoc);
|
|
200
|
+
if (!localHunks || !serverHunks) {
|
|
201
|
+
return { status: "failed", reason: "ambiguous" };
|
|
202
|
+
}
|
|
203
|
+
if (serverHunks.length === 0) return { status: "noop" };
|
|
204
|
+
if (
|
|
205
|
+
localHunks.some((local) =>
|
|
206
|
+
serverHunks.some((server) => hunksOverlap(local, server)),
|
|
207
|
+
)
|
|
208
|
+
) {
|
|
209
|
+
return { status: "conflict", localDraft: liveDoc };
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
try {
|
|
213
|
+
const tr = editor.state.tr;
|
|
214
|
+
for (const hunk of [...serverHunks].reverse()) {
|
|
215
|
+
const fromIndex = mappedLocalIndex(hunk.baseFromIndex, localHunks);
|
|
216
|
+
const toIndex = mappedLocalIndex(hunk.baseToIndex, localHunks);
|
|
217
|
+
tr.replaceWith(
|
|
218
|
+
positionOfChild(tr.doc, fromIndex),
|
|
219
|
+
positionOfChild(tr.doc, toIndex),
|
|
220
|
+
serverDoc.content.cut(
|
|
221
|
+
positionOfChild(serverDoc, hunk.changedFromIndex),
|
|
222
|
+
positionOfChild(serverDoc, hunk.changedToIndex),
|
|
223
|
+
),
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
tr.setMeta("addToHistory", false);
|
|
227
|
+
tr.setMeta(RICH_MARKDOWN_PROGRAMMATIC_TRANSACTION, true);
|
|
228
|
+
editor.view.dispatch(tr);
|
|
229
|
+
return { status: "applied", mergedDoc: editor.state.doc };
|
|
230
|
+
} catch {
|
|
231
|
+
return { status: "failed", reason: "transaction" };
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
44
235
|
/**
|
|
45
236
|
* Diff two documents at top-level-node granularity: trim the common prefix and
|
|
46
237
|
* suffix (node equality via ProseMirror's `Node.eq`, which is deep) and return
|
|
@@ -48,12 +48,16 @@ afterEach(() => {
|
|
|
48
48
|
interface HarnessProps {
|
|
49
49
|
value: string;
|
|
50
50
|
contentUpdatedAt: string;
|
|
51
|
+
contentRevision?: string;
|
|
51
52
|
editorOwnedFocus?: boolean;
|
|
52
53
|
}
|
|
53
54
|
|
|
54
55
|
interface CollabSeedHarnessProps {
|
|
55
56
|
collabSynced: boolean;
|
|
56
57
|
fragmentLength: number;
|
|
58
|
+
value?: string;
|
|
59
|
+
contentRevision?: string;
|
|
60
|
+
contentUpdatedAt?: string;
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
interface Captured {
|
|
@@ -61,6 +65,10 @@ interface Captured {
|
|
|
61
65
|
emitted: string[];
|
|
62
66
|
setContentCalls: number;
|
|
63
67
|
registerEmitted?: (markdown: string) => boolean;
|
|
68
|
+
reconciled?: Array<{
|
|
69
|
+
status: "merged" | "conflict" | "failed";
|
|
70
|
+
content: string;
|
|
71
|
+
}>;
|
|
64
72
|
}
|
|
65
73
|
|
|
66
74
|
function makeHarness() {
|
|
@@ -69,6 +77,7 @@ function makeHarness() {
|
|
|
69
77
|
function Harness({
|
|
70
78
|
value,
|
|
71
79
|
contentUpdatedAt,
|
|
80
|
+
contentRevision,
|
|
72
81
|
editorOwnedFocus = false,
|
|
73
82
|
}: HarnessProps) {
|
|
74
83
|
const guardsRef = React.useRef<ReturnType<
|
|
@@ -92,6 +101,13 @@ function makeHarness() {
|
|
|
92
101
|
editor,
|
|
93
102
|
value,
|
|
94
103
|
contentUpdatedAt,
|
|
104
|
+
contentRevision,
|
|
105
|
+
onBaseAwareReconcile: (result) => {
|
|
106
|
+
(captured.reconciled ??= []).push({
|
|
107
|
+
status: result.status,
|
|
108
|
+
content: result.content,
|
|
109
|
+
});
|
|
110
|
+
},
|
|
95
111
|
editable: true,
|
|
96
112
|
isEditorFocused: () => editorOwnedFocus,
|
|
97
113
|
getMarkdown: getEditorMarkdown,
|
|
@@ -122,7 +138,13 @@ function makeHarness() {
|
|
|
122
138
|
function makeCollabSeedHarness(initialContent = "") {
|
|
123
139
|
const captured: Captured = { editor: null, emitted: [], setContentCalls: 0 };
|
|
124
140
|
|
|
125
|
-
function Harness({
|
|
141
|
+
function Harness({
|
|
142
|
+
collabSynced,
|
|
143
|
+
fragmentLength,
|
|
144
|
+
value = "seeded content",
|
|
145
|
+
contentRevision,
|
|
146
|
+
contentUpdatedAt = "2024-01-01T00:00:01.000Z",
|
|
147
|
+
}: CollabSeedHarnessProps) {
|
|
126
148
|
const guardsRef = React.useRef<ReturnType<
|
|
127
149
|
typeof useCollabReconcile
|
|
128
150
|
> | null>(null);
|
|
@@ -153,8 +175,15 @@ function makeCollabSeedHarness(initialContent = "") {
|
|
|
153
175
|
editor,
|
|
154
176
|
ydoc: fakeYdoc as never,
|
|
155
177
|
collabSynced,
|
|
156
|
-
value
|
|
157
|
-
contentUpdatedAt
|
|
178
|
+
value,
|
|
179
|
+
contentUpdatedAt,
|
|
180
|
+
contentRevision,
|
|
181
|
+
onBaseAwareReconcile: (result) => {
|
|
182
|
+
(captured.reconciled ??= []).push({
|
|
183
|
+
status: result.status,
|
|
184
|
+
content: result.content,
|
|
185
|
+
});
|
|
186
|
+
},
|
|
158
187
|
editable: true,
|
|
159
188
|
getMarkdown: getEditorMarkdown,
|
|
160
189
|
setContent: (ed, v) => {
|
|
@@ -189,6 +218,62 @@ function render(
|
|
|
189
218
|
}
|
|
190
219
|
|
|
191
220
|
describe("useCollabReconcile — concurrent edit / lost-update guards", () => {
|
|
221
|
+
it("merges a newer non-overlapping revision and reports the combined draft", async () => {
|
|
222
|
+
const { captured, Harness } = makeHarness();
|
|
223
|
+
render(root, Harness, {
|
|
224
|
+
value: "Alpha\n\nBravo\n\nCharlie",
|
|
225
|
+
contentUpdatedAt: "2024-01-01T00:00:01.000Z",
|
|
226
|
+
contentRevision: "revision-1",
|
|
227
|
+
});
|
|
228
|
+
await flush();
|
|
229
|
+
|
|
230
|
+
act(() =>
|
|
231
|
+
captured.editor!.commands.setContent("Alpha local\n\nBravo\n\nCharlie"),
|
|
232
|
+
);
|
|
233
|
+
render(root, Harness, {
|
|
234
|
+
value: "Alpha\n\nBravo\n\nCharlie server",
|
|
235
|
+
contentUpdatedAt: "2024-01-01T00:00:02.000Z",
|
|
236
|
+
contentRevision: "revision-2",
|
|
237
|
+
});
|
|
238
|
+
await flush();
|
|
239
|
+
|
|
240
|
+
expect(captured.reconciled).toEqual([
|
|
241
|
+
{
|
|
242
|
+
status: "merged",
|
|
243
|
+
content: "Alpha local\n\nBravo\n\nCharlie server",
|
|
244
|
+
},
|
|
245
|
+
]);
|
|
246
|
+
expect(getEditorMarkdown(captured.editor!)).toBe(
|
|
247
|
+
"Alpha local\n\nBravo\n\nCharlie server",
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
it("reports an overlap once and preserves the local draft", async () => {
|
|
252
|
+
const { captured, Harness } = makeHarness();
|
|
253
|
+
render(root, Harness, {
|
|
254
|
+
value: "Alpha\n\nBravo",
|
|
255
|
+
contentUpdatedAt: "2024-01-01T00:00:01.000Z",
|
|
256
|
+
contentRevision: "revision-1",
|
|
257
|
+
});
|
|
258
|
+
await flush();
|
|
259
|
+
act(() => captured.editor!.commands.setContent("Alpha local\n\nBravo"));
|
|
260
|
+
|
|
261
|
+
const newer = {
|
|
262
|
+
value: "Alpha server\n\nBravo",
|
|
263
|
+
contentUpdatedAt: "2024-01-01T00:00:02.000Z",
|
|
264
|
+
contentRevision: "revision-2",
|
|
265
|
+
};
|
|
266
|
+
render(root, Harness, newer);
|
|
267
|
+
await flush();
|
|
268
|
+
render(root, Harness, newer);
|
|
269
|
+
await flush();
|
|
270
|
+
|
|
271
|
+
expect(getEditorMarkdown(captured.editor!)).toBe("Alpha local\n\nBravo");
|
|
272
|
+
expect(captured.reconciled).toEqual([
|
|
273
|
+
{ status: "conflict", content: "Alpha local\n\nBravo" },
|
|
274
|
+
]);
|
|
275
|
+
});
|
|
276
|
+
|
|
192
277
|
it("persists the first local edit after a synced empty collaborative document", async () => {
|
|
193
278
|
const captured: Captured = {
|
|
194
279
|
editor: null,
|
|
@@ -296,6 +381,47 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => {
|
|
|
296
381
|
expect(getEditorMarkdown(captured.editor!)).toBe("seeded content");
|
|
297
382
|
});
|
|
298
383
|
|
|
384
|
+
it("uses the collaborative seed as the base for a later three-way merge", async () => {
|
|
385
|
+
const { captured, Harness } = makeCollabSeedHarness();
|
|
386
|
+
act(() => {
|
|
387
|
+
root.render(
|
|
388
|
+
React.createElement(Harness, {
|
|
389
|
+
collabSynced: true,
|
|
390
|
+
fragmentLength: 0,
|
|
391
|
+
value: "Alpha\n\nBravo\n\nCharlie",
|
|
392
|
+
contentRevision: "revision-1",
|
|
393
|
+
}),
|
|
394
|
+
);
|
|
395
|
+
});
|
|
396
|
+
await flush();
|
|
397
|
+
|
|
398
|
+
act(() =>
|
|
399
|
+
captured.editor!.commands.setContent("Alpha local\n\nBravo\n\nCharlie"),
|
|
400
|
+
);
|
|
401
|
+
act(() => {
|
|
402
|
+
root.render(
|
|
403
|
+
React.createElement(Harness, {
|
|
404
|
+
collabSynced: true,
|
|
405
|
+
fragmentLength: 1,
|
|
406
|
+
value: "Alpha\n\nBravo\n\nCharlie server",
|
|
407
|
+
contentRevision: "revision-2",
|
|
408
|
+
contentUpdatedAt: "2024-01-01T00:00:02.000Z",
|
|
409
|
+
}),
|
|
410
|
+
);
|
|
411
|
+
});
|
|
412
|
+
await flush();
|
|
413
|
+
|
|
414
|
+
expect(captured.reconciled).toEqual([
|
|
415
|
+
{
|
|
416
|
+
status: "merged",
|
|
417
|
+
content: "Alpha local\n\nBravo\n\nCharlie server",
|
|
418
|
+
},
|
|
419
|
+
]);
|
|
420
|
+
expect(getEditorMarkdown(captured.editor!)).toBe(
|
|
421
|
+
"Alpha local\n\nBravo\n\nCharlie server",
|
|
422
|
+
);
|
|
423
|
+
});
|
|
424
|
+
|
|
299
425
|
it("applies a deliberate REVERT to a previously-applied value after a local edit (not swallowed as echo)", async () => {
|
|
300
426
|
// Regression for the revert-safety carve-out: the doc-equivalence echo
|
|
301
427
|
// guards (value === lastAppliedValueRef) only fire when the editor is
|
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
RICH_MARKDOWN_PROGRAMMATIC_TRANSACTION,
|
|
12
12
|
applyDocSurgically,
|
|
13
13
|
defaultParseValue,
|
|
14
|
+
reconcileDocAgainstBase,
|
|
14
15
|
} from "./surgical-apply.js";
|
|
15
16
|
|
|
16
17
|
export { RICH_MARKDOWN_PROGRAMMATIC_TRANSACTION };
|
|
@@ -60,6 +61,16 @@ export interface UseCollabReconcileOptions {
|
|
|
60
61
|
value: string;
|
|
61
62
|
/** Timestamp of the authoritative value; gates newer-than reconcile. */
|
|
62
63
|
contentUpdatedAt?: string | null;
|
|
64
|
+
/** Opaque authoritative body revision. Enables base-aware reconciliation. */
|
|
65
|
+
contentRevision?: string | null;
|
|
66
|
+
/** Reports an automatic merge to persist, or a conflict whose local draft was preserved. */
|
|
67
|
+
onBaseAwareReconcile?: (result: {
|
|
68
|
+
status: "merged" | "conflict" | "failed";
|
|
69
|
+
content: string;
|
|
70
|
+
serverContent: string;
|
|
71
|
+
baseRevision: string;
|
|
72
|
+
serverRevision: string;
|
|
73
|
+
}) => void;
|
|
63
74
|
/** Whether the editor accepts edits. Reconcile/seed only run for the live editor. */
|
|
64
75
|
editable: boolean;
|
|
65
76
|
/**
|
|
@@ -234,6 +245,8 @@ export function useCollabReconcile({
|
|
|
234
245
|
awareness = null,
|
|
235
246
|
value,
|
|
236
247
|
contentUpdatedAt,
|
|
248
|
+
contentRevision,
|
|
249
|
+
onBaseAwareReconcile,
|
|
237
250
|
editable,
|
|
238
251
|
isEditorFocused = defaultIsEditorFocused,
|
|
239
252
|
getMarkdown = getEditorMarkdown,
|
|
@@ -272,6 +285,11 @@ export function useCollabReconcile({
|
|
|
272
285
|
? initialAppliedUpdatedAt
|
|
273
286
|
: (contentUpdatedAt ?? null),
|
|
274
287
|
);
|
|
288
|
+
const authoritativeBaseRef = useRef<{
|
|
289
|
+
value: string;
|
|
290
|
+
revision: string;
|
|
291
|
+
} | null>(contentRevision ? { value, revision: contentRevision } : null);
|
|
292
|
+
const reportedConflictRevisionRef = useRef<string | null>(null);
|
|
275
293
|
|
|
276
294
|
// Whether THIS client is the one that seeds the empty shared doc / applies an
|
|
277
295
|
// authoritative external snapshot into it. Exactly one client does, so the
|
|
@@ -329,6 +347,10 @@ export function useCollabReconcile({
|
|
|
329
347
|
if (!collab || !editor || editor.isDestroyed || !ydoc) return;
|
|
330
348
|
if (seededRef.current) return;
|
|
331
349
|
if (!collabSynced) return;
|
|
350
|
+
if (contentRevision) {
|
|
351
|
+
authoritativeBaseRef.current = { value, revision: contentRevision };
|
|
352
|
+
reportedConflictRevisionRef.current = null;
|
|
353
|
+
}
|
|
332
354
|
// An empty SQL value has nothing to seed. Release the first real keystroke
|
|
333
355
|
// immediately, but when a fragment already exists defer the ambiguous
|
|
334
356
|
// reconcile decision for one task: active-peer or just-emitted local content
|
|
@@ -431,6 +453,7 @@ export function useCollabReconcile({
|
|
|
431
453
|
value,
|
|
432
454
|
isLeadClient,
|
|
433
455
|
contentUpdatedAt,
|
|
456
|
+
contentRevision,
|
|
434
457
|
getMarkdown,
|
|
435
458
|
setContent,
|
|
436
459
|
shouldSeed,
|
|
@@ -519,6 +542,10 @@ export function useCollabReconcile({
|
|
|
519
542
|
(value === lastAppliedValueRef.current ||
|
|
520
543
|
normalizedValue === lastAppliedSerializedRef.current))
|
|
521
544
|
) {
|
|
545
|
+
if (contentRevision) {
|
|
546
|
+
authoritativeBaseRef.current = { value, revision: contentRevision };
|
|
547
|
+
reportedConflictRevisionRef.current = null;
|
|
548
|
+
}
|
|
522
549
|
if (contentUpdatedAt) {
|
|
523
550
|
lastAppliedUpdatedAtRef.current = contentUpdatedAt;
|
|
524
551
|
}
|
|
@@ -596,6 +623,74 @@ export function useCollabReconcile({
|
|
|
596
623
|
return;
|
|
597
624
|
}
|
|
598
625
|
isSettingContentRef.current = true;
|
|
626
|
+
const authoritativeBase = authoritativeBaseRef.current;
|
|
627
|
+
if (
|
|
628
|
+
contentRevision &&
|
|
629
|
+
onBaseAwareReconcile &&
|
|
630
|
+
authoritativeBase &&
|
|
631
|
+
authoritativeBase.revision !== contentRevision
|
|
632
|
+
) {
|
|
633
|
+
const parse =
|
|
634
|
+
parseValue === false ? null : (parseValue ?? defaultParseValue);
|
|
635
|
+
const baseDoc = parse?.(editor, authoritativeBase.value) ?? null;
|
|
636
|
+
const serverDoc = parse?.(editor, value) ?? null;
|
|
637
|
+
if (!baseDoc || !serverDoc) {
|
|
638
|
+
isSettingContentRef.current = false;
|
|
639
|
+
if (reportedConflictRevisionRef.current !== contentRevision) {
|
|
640
|
+
reportedConflictRevisionRef.current = contentRevision;
|
|
641
|
+
onBaseAwareReconcile({
|
|
642
|
+
status: "failed",
|
|
643
|
+
content: beforeMarkdown,
|
|
644
|
+
serverContent: value,
|
|
645
|
+
baseRevision: authoritativeBase.revision,
|
|
646
|
+
serverRevision: contentRevision,
|
|
647
|
+
});
|
|
648
|
+
}
|
|
649
|
+
return;
|
|
650
|
+
}
|
|
651
|
+
const reconciled = reconcileDocAgainstBase(
|
|
652
|
+
editor,
|
|
653
|
+
baseDoc,
|
|
654
|
+
serverDoc,
|
|
655
|
+
);
|
|
656
|
+
if (
|
|
657
|
+
reconciled.status === "conflict" ||
|
|
658
|
+
reconciled.status === "failed"
|
|
659
|
+
) {
|
|
660
|
+
isSettingContentRef.current = false;
|
|
661
|
+
if (reportedConflictRevisionRef.current !== contentRevision) {
|
|
662
|
+
reportedConflictRevisionRef.current = contentRevision;
|
|
663
|
+
onBaseAwareReconcile({
|
|
664
|
+
status: reconciled.status,
|
|
665
|
+
content: beforeMarkdown,
|
|
666
|
+
serverContent: value,
|
|
667
|
+
baseRevision: authoritativeBase.revision,
|
|
668
|
+
serverRevision: contentRevision,
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
return;
|
|
672
|
+
}
|
|
673
|
+
const merged = getMarkdown(editor);
|
|
674
|
+
isSettingContentRef.current = false;
|
|
675
|
+
authoritativeBaseRef.current = { value, revision: contentRevision };
|
|
676
|
+
reportedConflictRevisionRef.current = null;
|
|
677
|
+
lastEmittedRef.current = merged;
|
|
678
|
+
pushEmittedRing(recentEmittedRef.current, merged);
|
|
679
|
+
lastAppliedValueRef.current = value;
|
|
680
|
+
lastAppliedSerializedRef.current = merged;
|
|
681
|
+
if (contentUpdatedAt)
|
|
682
|
+
lastAppliedUpdatedAtRef.current = contentUpdatedAt;
|
|
683
|
+
if (reconciled.status === "applied" && merged !== normalized) {
|
|
684
|
+
onBaseAwareReconcile({
|
|
685
|
+
status: "merged",
|
|
686
|
+
content: merged,
|
|
687
|
+
serverContent: value,
|
|
688
|
+
baseRevision: authoritativeBase.revision,
|
|
689
|
+
serverRevision: contentRevision,
|
|
690
|
+
});
|
|
691
|
+
}
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
599
694
|
// Surgical path first: replace only the changed top-level run so
|
|
600
695
|
// unchanged block NodeViews are never torn down and (under
|
|
601
696
|
// Collaboration) Yjs sees a minimal edit instead of a full-fragment
|
|
@@ -623,6 +718,10 @@ export function useCollabReconcile({
|
|
|
623
718
|
pushEmittedRing(recentEmittedRef.current, serialized);
|
|
624
719
|
lastAppliedValueRef.current = value;
|
|
625
720
|
lastAppliedSerializedRef.current = serialized;
|
|
721
|
+
if (contentRevision) {
|
|
722
|
+
authoritativeBaseRef.current = { value, revision: contentRevision };
|
|
723
|
+
reportedConflictRevisionRef.current = null;
|
|
724
|
+
}
|
|
626
725
|
if (contentUpdatedAt) {
|
|
627
726
|
lastAppliedUpdatedAtRef.current = contentUpdatedAt;
|
|
628
727
|
}
|
|
@@ -637,6 +736,7 @@ export function useCollabReconcile({
|
|
|
637
736
|
};
|
|
638
737
|
}, [
|
|
639
738
|
contentUpdatedAt,
|
|
739
|
+
contentRevision,
|
|
640
740
|
editor,
|
|
641
741
|
value,
|
|
642
742
|
collab,
|
|
@@ -647,6 +747,7 @@ export function useCollabReconcile({
|
|
|
647
747
|
parseValue,
|
|
648
748
|
normalizeValue,
|
|
649
749
|
isEditorFocused,
|
|
750
|
+
onBaseAwareReconcile,
|
|
650
751
|
]);
|
|
651
752
|
|
|
652
753
|
const shouldIgnoreUpdate = (transaction: Transaction): boolean => {
|