@agent-native/toolkit 0.20.4-nightly-20260917183518 → 0.20.4
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/design-system/default-adapter.d.ts.map +1 -1
- package/dist/design-system/default-adapter.js +4 -2
- package/dist/design-system/default-adapter.js.map +1 -1
- package/dist/design-system/default-adapter.spec.js +8 -0
- package/dist/design-system/default-adapter.spec.js.map +1 -1
- package/dist/design-system/design-system.spec.js +31 -2
- package/dist/design-system/design-system.spec.js.map +1 -1
- package/dist/design-system/types.d.ts +2 -0
- package/dist/design-system/types.d.ts.map +1 -1
- package/dist/editor/useCollabReconcile.concurrent.spec.js +282 -1
- package/dist/editor/useCollabReconcile.concurrent.spec.js.map +1 -1
- package/dist/editor/useCollabReconcile.d.ts +7 -1
- package/dist/editor/useCollabReconcile.d.ts.map +1 -1
- package/dist/editor/useCollabReconcile.js +89 -8
- package/dist/editor/useCollabReconcile.js.map +1 -1
- package/dist/provider.d.ts.map +1 -1
- package/dist/provider.js +12 -10
- package/dist/provider.js.map +1 -1
- package/dist/ui/button.d.ts +3 -2
- package/dist/ui/button.d.ts.map +1 -1
- package/dist/ui/button.js +15 -7
- package/dist/ui/button.js.map +1 -1
- package/package.json +1 -1
- package/src/design-system/default-adapter.spec.tsx +10 -0
- package/src/design-system/default-adapter.tsx +12 -4
- package/src/design-system/design-system.spec.tsx +65 -2
- package/src/design-system/types.ts +2 -0
- package/src/editor/useCollabReconcile.concurrent.spec.ts +357 -0
- package/src/editor/useCollabReconcile.ts +120 -9
- package/src/provider.tsx +12 -9
- package/src/ui/button.tsx +21 -8
|
@@ -49,6 +49,7 @@ interface HarnessProps {
|
|
|
49
49
|
value: string;
|
|
50
50
|
contentUpdatedAt: string;
|
|
51
51
|
contentRevision?: string;
|
|
52
|
+
compareContentRevisions?: (first: string, second: string) => number | null;
|
|
52
53
|
acknowledgedLocalSnapshot?: {
|
|
53
54
|
value: string;
|
|
54
55
|
revision: string;
|
|
@@ -86,6 +87,7 @@ function makeHarness() {
|
|
|
86
87
|
value,
|
|
87
88
|
contentUpdatedAt,
|
|
88
89
|
contentRevision,
|
|
90
|
+
compareContentRevisions,
|
|
89
91
|
acknowledgedLocalSnapshot,
|
|
90
92
|
editorOwnedFocus = false,
|
|
91
93
|
isEditorFocused,
|
|
@@ -112,6 +114,7 @@ function makeHarness() {
|
|
|
112
114
|
value,
|
|
113
115
|
contentUpdatedAt,
|
|
114
116
|
contentRevision,
|
|
117
|
+
compareContentRevisions,
|
|
115
118
|
acknowledgedLocalSnapshot,
|
|
116
119
|
onBaseAwareReconcile: (result) => {
|
|
117
120
|
(captured.reconciled ??= []).push({
|
|
@@ -629,6 +632,50 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => {
|
|
|
629
632
|
},
|
|
630
633
|
);
|
|
631
634
|
|
|
635
|
+
it("does not replay sequential SQL acknowledgements over two-tab Yjs edits", async () => {
|
|
636
|
+
const harness = makeConnectedEditorHarness(false);
|
|
637
|
+
const baseline = "Alpha\n\nBravo";
|
|
638
|
+
const firstSave = "Alpha\n\nBravo from first tab";
|
|
639
|
+
const merged = "Second tab Alpha\n\nBravo from first tab";
|
|
640
|
+
vi.useFakeTimers();
|
|
641
|
+
try {
|
|
642
|
+
render(root, harness.Harness, {
|
|
643
|
+
value: baseline,
|
|
644
|
+
contentUpdatedAt: "2024-01-01T00:00:01.000Z",
|
|
645
|
+
contentRevision: "revision-1",
|
|
646
|
+
});
|
|
647
|
+
await act(async () => vi.advanceTimersByTimeAsync(30));
|
|
648
|
+
|
|
649
|
+
act(() => harness.editors[0]!.commands.setContent(firstSave));
|
|
650
|
+
act(() => harness.editors[1]!.commands.setContent(merged));
|
|
651
|
+
expect(harness.markdown()).toEqual([merged, merged]);
|
|
652
|
+
|
|
653
|
+
render(root, harness.Harness, {
|
|
654
|
+
value: firstSave,
|
|
655
|
+
contentUpdatedAt: "2024-01-01T00:00:02.000Z",
|
|
656
|
+
contentRevision: "revision-2",
|
|
657
|
+
});
|
|
658
|
+
await act(async () => vi.advanceTimersByTimeAsync(2501));
|
|
659
|
+
render(root, harness.Harness, {
|
|
660
|
+
value: merged,
|
|
661
|
+
contentUpdatedAt: "2024-01-01T00:00:03.000Z",
|
|
662
|
+
contentRevision: "revision-3",
|
|
663
|
+
});
|
|
664
|
+
await act(async () => vi.advanceTimersByTimeAsync(2501));
|
|
665
|
+
|
|
666
|
+
expect(harness.markdown()).toEqual([merged, merged]);
|
|
667
|
+
expect(harness.markdown()[0]!.match(/Second tab Alpha/g)).toHaveLength(1);
|
|
668
|
+
expect(
|
|
669
|
+
harness.markdown()[0]!.match(/Bravo from first tab/g),
|
|
670
|
+
).toHaveLength(1);
|
|
671
|
+
expect(harness.reconciled).toEqual([
|
|
672
|
+
{ status: "merged", content: merged },
|
|
673
|
+
]);
|
|
674
|
+
} finally {
|
|
675
|
+
harness.dispose();
|
|
676
|
+
}
|
|
677
|
+
});
|
|
678
|
+
|
|
632
679
|
it("uses the latest callback at the original peer deadline, including the default normalizer", async () => {
|
|
633
680
|
const harness = makePeerReconcileHarness();
|
|
634
681
|
vi.useFakeTimers();
|
|
@@ -1224,6 +1271,316 @@ describe("useCollabReconcile — concurrent edit / lost-update guards", () => {
|
|
|
1224
1271
|
]);
|
|
1225
1272
|
});
|
|
1226
1273
|
|
|
1274
|
+
it("does not replace an external base with an acknowledgement at the same timestamp", async () => {
|
|
1275
|
+
vi.useFakeTimers();
|
|
1276
|
+
const { captured, Harness } = makeHarness();
|
|
1277
|
+
const compareContentRevisions = (first: string, second: string) =>
|
|
1278
|
+
Number(first.split("-")[1]) - Number(second.split("-")[1]);
|
|
1279
|
+
render(root, Harness, {
|
|
1280
|
+
value: "Alpha saved\n\nBravo\n\nCharlie",
|
|
1281
|
+
contentUpdatedAt: "2024-01-01T00:00:01.000Z",
|
|
1282
|
+
contentRevision: "revision-1",
|
|
1283
|
+
compareContentRevisions,
|
|
1284
|
+
});
|
|
1285
|
+
await act(async () => vi.advanceTimersByTimeAsync(0));
|
|
1286
|
+
|
|
1287
|
+
act(() =>
|
|
1288
|
+
captured.editor!.commands.setContent(
|
|
1289
|
+
"Alpha saved\n\nBravo\n\nCharlie local",
|
|
1290
|
+
),
|
|
1291
|
+
);
|
|
1292
|
+
render(root, Harness, {
|
|
1293
|
+
value: "Alpha saved\n\nBravo server\n\nCharlie",
|
|
1294
|
+
contentUpdatedAt: "2024-01-01T00:00:02.000Z",
|
|
1295
|
+
contentRevision: "revision-3",
|
|
1296
|
+
compareContentRevisions,
|
|
1297
|
+
});
|
|
1298
|
+
await act(async () => vi.advanceTimersByTimeAsync(0));
|
|
1299
|
+
expect(getEditorMarkdown(captured.editor!)).toBe(
|
|
1300
|
+
"Alpha saved\n\nBravo server\n\nCharlie local",
|
|
1301
|
+
);
|
|
1302
|
+
|
|
1303
|
+
const staleAcknowledgement = {
|
|
1304
|
+
value: "Alpha saved\n\nBravo\n\nCharlie",
|
|
1305
|
+
revision: "revision-2",
|
|
1306
|
+
updatedAt: "2024-01-01T00:00:02.000Z",
|
|
1307
|
+
sequence: 1,
|
|
1308
|
+
};
|
|
1309
|
+
render(root, Harness, {
|
|
1310
|
+
value: staleAcknowledgement.value,
|
|
1311
|
+
contentUpdatedAt: staleAcknowledgement.updatedAt,
|
|
1312
|
+
contentRevision: staleAcknowledgement.revision,
|
|
1313
|
+
compareContentRevisions,
|
|
1314
|
+
acknowledgedLocalSnapshot: staleAcknowledgement,
|
|
1315
|
+
});
|
|
1316
|
+
await act(async () => vi.advanceTimersByTimeAsync(0));
|
|
1317
|
+
|
|
1318
|
+
render(root, Harness, {
|
|
1319
|
+
value: "Alpha saved\n\nBravo remote\n\nCharlie",
|
|
1320
|
+
contentUpdatedAt: "2024-01-01T00:00:03.000Z",
|
|
1321
|
+
contentRevision: "revision-4",
|
|
1322
|
+
compareContentRevisions,
|
|
1323
|
+
acknowledgedLocalSnapshot: staleAcknowledgement,
|
|
1324
|
+
});
|
|
1325
|
+
await act(async () => vi.advanceTimersByTimeAsync(0));
|
|
1326
|
+
|
|
1327
|
+
expect(getEditorMarkdown(captured.editor!)).toBe(
|
|
1328
|
+
"Alpha saved\n\nBravo remote\n\nCharlie local",
|
|
1329
|
+
);
|
|
1330
|
+
expect(captured.reconciled).toEqual([
|
|
1331
|
+
{
|
|
1332
|
+
status: "merged",
|
|
1333
|
+
content: "Alpha saved\n\nBravo server\n\nCharlie local",
|
|
1334
|
+
},
|
|
1335
|
+
{
|
|
1336
|
+
status: "merged",
|
|
1337
|
+
content: "Alpha saved\n\nBravo remote\n\nCharlie local",
|
|
1338
|
+
},
|
|
1339
|
+
]);
|
|
1340
|
+
});
|
|
1341
|
+
|
|
1342
|
+
it("restores the prior base when an external revision follows an equal-timestamp acknowledgement", async () => {
|
|
1343
|
+
vi.useFakeTimers();
|
|
1344
|
+
const { captured, Harness } = makeHarness();
|
|
1345
|
+
render(root, Harness, {
|
|
1346
|
+
value: "Alpha\n\nBravo\n\nCharlie",
|
|
1347
|
+
contentUpdatedAt: "2024-01-01T00:00:01.000Z",
|
|
1348
|
+
contentRevision: "revision-1",
|
|
1349
|
+
});
|
|
1350
|
+
await act(async () => vi.advanceTimersByTimeAsync(0));
|
|
1351
|
+
|
|
1352
|
+
act(() =>
|
|
1353
|
+
captured.editor!.commands.setContent(
|
|
1354
|
+
"Alpha local\n\nBravo\n\nCharlie local",
|
|
1355
|
+
),
|
|
1356
|
+
);
|
|
1357
|
+
const acknowledgement = {
|
|
1358
|
+
value: "Alpha local\n\nBravo\n\nCharlie",
|
|
1359
|
+
revision: "revision-2",
|
|
1360
|
+
updatedAt: "2024-01-01T00:00:02.000Z",
|
|
1361
|
+
sequence: 1,
|
|
1362
|
+
};
|
|
1363
|
+
render(root, Harness, {
|
|
1364
|
+
value: acknowledgement.value,
|
|
1365
|
+
contentUpdatedAt: acknowledgement.updatedAt,
|
|
1366
|
+
contentRevision: acknowledgement.revision,
|
|
1367
|
+
acknowledgedLocalSnapshot: acknowledgement,
|
|
1368
|
+
});
|
|
1369
|
+
await act(async () => vi.advanceTimersByTimeAsync(0));
|
|
1370
|
+
expect(getEditorMarkdown(captured.editor!)).toBe(
|
|
1371
|
+
"Alpha local\n\nBravo\n\nCharlie local",
|
|
1372
|
+
);
|
|
1373
|
+
|
|
1374
|
+
render(root, Harness, {
|
|
1375
|
+
value: "Alpha external\n\nBravo server\n\nCharlie",
|
|
1376
|
+
contentUpdatedAt: acknowledgement.updatedAt,
|
|
1377
|
+
contentRevision: "revision-3",
|
|
1378
|
+
acknowledgedLocalSnapshot: acknowledgement,
|
|
1379
|
+
});
|
|
1380
|
+
await act(async () => vi.advanceTimersByTimeAsync(0));
|
|
1381
|
+
|
|
1382
|
+
expect(getEditorMarkdown(captured.editor!)).toBe(
|
|
1383
|
+
"Alpha local\n\nBravo\n\nCharlie local",
|
|
1384
|
+
);
|
|
1385
|
+
expect(captured.reconciled).toEqual([
|
|
1386
|
+
{
|
|
1387
|
+
status: "conflict",
|
|
1388
|
+
content: "Alpha local\n\nBravo\n\nCharlie local",
|
|
1389
|
+
},
|
|
1390
|
+
]);
|
|
1391
|
+
});
|
|
1392
|
+
|
|
1393
|
+
it("preserves the prior base when an acknowledgement precedes its canonical snapshot", async () => {
|
|
1394
|
+
vi.useFakeTimers();
|
|
1395
|
+
const { captured, Harness } = makeHarness();
|
|
1396
|
+
const initial = {
|
|
1397
|
+
value: "Alpha\n\nBravo\n\nCharlie",
|
|
1398
|
+
contentUpdatedAt: "2024-01-01T00:00:01.000Z",
|
|
1399
|
+
contentRevision: "revision-1",
|
|
1400
|
+
};
|
|
1401
|
+
render(root, Harness, initial);
|
|
1402
|
+
await act(async () => vi.advanceTimersByTimeAsync(0));
|
|
1403
|
+
|
|
1404
|
+
act(() =>
|
|
1405
|
+
captured.editor!.commands.setContent(
|
|
1406
|
+
"Alpha local\n\nBravo\n\nCharlie local",
|
|
1407
|
+
),
|
|
1408
|
+
);
|
|
1409
|
+
const acknowledgement = {
|
|
1410
|
+
value: "Alpha local\n\nBravo\n\nCharlie",
|
|
1411
|
+
revision: "revision-2",
|
|
1412
|
+
updatedAt: "2024-01-01T00:00:02.000Z",
|
|
1413
|
+
sequence: 1,
|
|
1414
|
+
};
|
|
1415
|
+
render(root, Harness, {
|
|
1416
|
+
...initial,
|
|
1417
|
+
acknowledgedLocalSnapshot: acknowledgement,
|
|
1418
|
+
});
|
|
1419
|
+
await act(async () => vi.advanceTimersByTimeAsync(0));
|
|
1420
|
+
|
|
1421
|
+
render(root, Harness, {
|
|
1422
|
+
value: "Alpha external\n\nBravo server\n\nCharlie",
|
|
1423
|
+
contentUpdatedAt: acknowledgement.updatedAt,
|
|
1424
|
+
contentRevision: "revision-3",
|
|
1425
|
+
acknowledgedLocalSnapshot: acknowledgement,
|
|
1426
|
+
});
|
|
1427
|
+
await act(async () => vi.advanceTimersByTimeAsync(0));
|
|
1428
|
+
|
|
1429
|
+
expect(getEditorMarkdown(captured.editor!)).toBe(
|
|
1430
|
+
"Alpha local\n\nBravo\n\nCharlie local",
|
|
1431
|
+
);
|
|
1432
|
+
expect(captured.reconciled).toEqual([
|
|
1433
|
+
{
|
|
1434
|
+
status: "conflict",
|
|
1435
|
+
content: "Alpha local\n\nBravo\n\nCharlie local",
|
|
1436
|
+
},
|
|
1437
|
+
]);
|
|
1438
|
+
});
|
|
1439
|
+
|
|
1440
|
+
it("ignores a delayed different revision at an accepted timestamp", async () => {
|
|
1441
|
+
vi.useFakeTimers();
|
|
1442
|
+
const { captured, Harness } = makeHarness();
|
|
1443
|
+
render(root, Harness, {
|
|
1444
|
+
value: "Alpha\n\nBravo\n\nCharlie",
|
|
1445
|
+
contentUpdatedAt: "2024-01-01T00:00:01.000Z",
|
|
1446
|
+
contentRevision: "revision-1",
|
|
1447
|
+
});
|
|
1448
|
+
await act(async () => vi.advanceTimersByTimeAsync(0));
|
|
1449
|
+
|
|
1450
|
+
act(() =>
|
|
1451
|
+
captured.editor!.commands.setContent("Alpha\n\nBravo\n\nCharlie local"),
|
|
1452
|
+
);
|
|
1453
|
+
render(root, Harness, {
|
|
1454
|
+
value: "Alpha\n\nBravo server\n\nCharlie",
|
|
1455
|
+
contentUpdatedAt: "2024-01-01T00:00:02.000Z",
|
|
1456
|
+
contentRevision: "body:3:sha256:newest",
|
|
1457
|
+
compareContentRevisions: (first, second) =>
|
|
1458
|
+
Number(first.split(":")[1]) - Number(second.split(":")[1]),
|
|
1459
|
+
});
|
|
1460
|
+
await act(async () => vi.advanceTimersByTimeAsync(0));
|
|
1461
|
+
expect(getEditorMarkdown(captured.editor!)).toBe(
|
|
1462
|
+
"Alpha\n\nBravo server\n\nCharlie local",
|
|
1463
|
+
);
|
|
1464
|
+
|
|
1465
|
+
render(root, Harness, {
|
|
1466
|
+
value: "Alpha stale\n\nBravo\n\nCharlie",
|
|
1467
|
+
contentUpdatedAt: "2024-01-01T00:00:02.000Z",
|
|
1468
|
+
contentRevision: "body:2:sha256:delayed",
|
|
1469
|
+
compareContentRevisions: (first, second) =>
|
|
1470
|
+
Number(first.split(":")[1]) - Number(second.split(":")[1]),
|
|
1471
|
+
});
|
|
1472
|
+
await act(async () => vi.advanceTimersByTimeAsync(0));
|
|
1473
|
+
|
|
1474
|
+
expect(getEditorMarkdown(captured.editor!)).toBe(
|
|
1475
|
+
"Alpha\n\nBravo server\n\nCharlie local",
|
|
1476
|
+
);
|
|
1477
|
+
expect(captured.reconciled).toEqual([
|
|
1478
|
+
{
|
|
1479
|
+
status: "merged",
|
|
1480
|
+
content: "Alpha\n\nBravo server\n\nCharlie local",
|
|
1481
|
+
},
|
|
1482
|
+
]);
|
|
1483
|
+
});
|
|
1484
|
+
|
|
1485
|
+
it("accepts a newer acknowledgement at an observed timestamp as the merge base", async () => {
|
|
1486
|
+
vi.useFakeTimers();
|
|
1487
|
+
const { captured, Harness } = makeHarness();
|
|
1488
|
+
const compareContentRevisions = (first: string, second: string) =>
|
|
1489
|
+
Number(first.split(":")[1]) - Number(second.split(":")[1]);
|
|
1490
|
+
const timestamp = "2024-01-01T00:00:02.000Z";
|
|
1491
|
+
render(root, Harness, {
|
|
1492
|
+
value: "Alpha\n\nBravo\n\nCharlie",
|
|
1493
|
+
contentUpdatedAt: timestamp,
|
|
1494
|
+
contentRevision: "body:1:sha256:initial",
|
|
1495
|
+
compareContentRevisions,
|
|
1496
|
+
});
|
|
1497
|
+
await act(async () => vi.advanceTimersByTimeAsync(0));
|
|
1498
|
+
|
|
1499
|
+
act(() =>
|
|
1500
|
+
captured.editor!.commands.setContent(
|
|
1501
|
+
"Alpha local\n\nBravo\n\nCharlie local",
|
|
1502
|
+
),
|
|
1503
|
+
);
|
|
1504
|
+
const acknowledgement = {
|
|
1505
|
+
value: "Alpha local\n\nBravo\n\nCharlie",
|
|
1506
|
+
revision: "body:2:sha256:acknowledged",
|
|
1507
|
+
updatedAt: timestamp,
|
|
1508
|
+
sequence: 1,
|
|
1509
|
+
};
|
|
1510
|
+
render(root, Harness, {
|
|
1511
|
+
value: "Alpha\n\nBravo\n\nCharlie",
|
|
1512
|
+
contentUpdatedAt: timestamp,
|
|
1513
|
+
contentRevision: "body:1:sha256:initial",
|
|
1514
|
+
compareContentRevisions,
|
|
1515
|
+
acknowledgedLocalSnapshot: acknowledgement,
|
|
1516
|
+
});
|
|
1517
|
+
await act(async () => vi.advanceTimersByTimeAsync(0));
|
|
1518
|
+
|
|
1519
|
+
render(root, Harness, {
|
|
1520
|
+
value: "Alpha external\n\nBravo server\n\nCharlie",
|
|
1521
|
+
contentUpdatedAt: timestamp,
|
|
1522
|
+
contentRevision: "body:3:sha256:external",
|
|
1523
|
+
compareContentRevisions,
|
|
1524
|
+
acknowledgedLocalSnapshot: acknowledgement,
|
|
1525
|
+
});
|
|
1526
|
+
await act(async () => vi.advanceTimersByTimeAsync(0));
|
|
1527
|
+
|
|
1528
|
+
expect(getEditorMarkdown(captured.editor!)).toBe(
|
|
1529
|
+
"Alpha external\n\nBravo server\n\nCharlie local",
|
|
1530
|
+
);
|
|
1531
|
+
expect(captured.reconciled?.at(-1)).toEqual({
|
|
1532
|
+
status: "merged",
|
|
1533
|
+
content: "Alpha external\n\nBravo server\n\nCharlie local",
|
|
1534
|
+
});
|
|
1535
|
+
});
|
|
1536
|
+
|
|
1537
|
+
it("accepts an opaque acknowledgement at an observed timestamp as the merge base", async () => {
|
|
1538
|
+
vi.useFakeTimers();
|
|
1539
|
+
const { captured, Harness } = makeHarness();
|
|
1540
|
+
const timestamp = "2024-01-01T00:00:02.000Z";
|
|
1541
|
+
render(root, Harness, {
|
|
1542
|
+
value: "Alpha\n\nBravo\n\nCharlie",
|
|
1543
|
+
contentUpdatedAt: timestamp,
|
|
1544
|
+
contentRevision: "initial",
|
|
1545
|
+
});
|
|
1546
|
+
await act(async () => vi.advanceTimersByTimeAsync(0));
|
|
1547
|
+
|
|
1548
|
+
act(() =>
|
|
1549
|
+
captured.editor!.commands.setContent(
|
|
1550
|
+
"Alpha local\n\nBravo\n\nCharlie local",
|
|
1551
|
+
),
|
|
1552
|
+
);
|
|
1553
|
+
const acknowledgement = {
|
|
1554
|
+
value: "Alpha local\n\nBravo\n\nCharlie",
|
|
1555
|
+
revision: "acknowledged",
|
|
1556
|
+
updatedAt: timestamp,
|
|
1557
|
+
sequence: 1,
|
|
1558
|
+
};
|
|
1559
|
+
render(root, Harness, {
|
|
1560
|
+
value: "Alpha\n\nBravo\n\nCharlie",
|
|
1561
|
+
contentUpdatedAt: timestamp,
|
|
1562
|
+
contentRevision: "initial",
|
|
1563
|
+
acknowledgedLocalSnapshot: acknowledgement,
|
|
1564
|
+
});
|
|
1565
|
+
await act(async () => vi.advanceTimersByTimeAsync(0));
|
|
1566
|
+
|
|
1567
|
+
render(root, Harness, {
|
|
1568
|
+
value: "Alpha external\n\nBravo server\n\nCharlie",
|
|
1569
|
+
contentUpdatedAt: timestamp,
|
|
1570
|
+
contentRevision: "external",
|
|
1571
|
+
acknowledgedLocalSnapshot: acknowledgement,
|
|
1572
|
+
});
|
|
1573
|
+
await act(async () => vi.advanceTimersByTimeAsync(0));
|
|
1574
|
+
|
|
1575
|
+
expect(getEditorMarkdown(captured.editor!)).toBe(
|
|
1576
|
+
"Alpha external\n\nBravo server\n\nCharlie local",
|
|
1577
|
+
);
|
|
1578
|
+
expect(captured.reconciled?.at(-1)).toEqual({
|
|
1579
|
+
status: "merged",
|
|
1580
|
+
content: "Alpha external\n\nBravo server\n\nCharlie local",
|
|
1581
|
+
});
|
|
1582
|
+
});
|
|
1583
|
+
|
|
1227
1584
|
it("persists the first local edit after a synced empty collaborative document", async () => {
|
|
1228
1585
|
const captured: Captured = {
|
|
1229
1586
|
editor: null,
|
|
@@ -63,6 +63,12 @@ export interface UseCollabReconcileOptions {
|
|
|
63
63
|
contentUpdatedAt?: string | null;
|
|
64
64
|
/** Opaque authoritative body revision. Enables base-aware reconciliation. */
|
|
65
65
|
contentRevision?: string | null;
|
|
66
|
+
/**
|
|
67
|
+
* Orders two revision identities when their timestamps tie. Return a positive
|
|
68
|
+
* number when the first revision is newer, zero when equivalent, a negative
|
|
69
|
+
* number when older, or null when this revision format cannot be ordered.
|
|
70
|
+
*/
|
|
71
|
+
compareContentRevisions?: (first: string, second: string) => number | null;
|
|
66
72
|
/**
|
|
67
73
|
* A server-confirmed snapshot written by this editor. This is deliberately
|
|
68
74
|
* separate from `registerEmitted`: an emitted value may still fail to save,
|
|
@@ -263,6 +269,7 @@ export function useCollabReconcile({
|
|
|
263
269
|
value,
|
|
264
270
|
contentUpdatedAt,
|
|
265
271
|
contentRevision,
|
|
272
|
+
compareContentRevisions,
|
|
266
273
|
acknowledgedLocalSnapshot,
|
|
267
274
|
collabContentRevision,
|
|
268
275
|
requestCollabSync,
|
|
@@ -323,6 +330,14 @@ export function useCollabReconcile({
|
|
|
323
330
|
const latestObservedUpdatedAtRef = useRef<string | null>(
|
|
324
331
|
contentUpdatedAt ?? null,
|
|
325
332
|
);
|
|
333
|
+
const latestObservedRevisionRef = useRef<string | null>(
|
|
334
|
+
contentRevision ?? null,
|
|
335
|
+
);
|
|
336
|
+
const acknowledgementBaseRollbackRef = useRef<{
|
|
337
|
+
acknowledgementRevision: string;
|
|
338
|
+
updatedAt: string;
|
|
339
|
+
base: { value: string; revision: string } | null;
|
|
340
|
+
} | null>(null);
|
|
326
341
|
const acknowledgedCollabRef = useRef<{ ydoc: YDoc; revision: string } | null>(
|
|
327
342
|
null,
|
|
328
343
|
);
|
|
@@ -616,28 +631,115 @@ export function useCollabReconcile({
|
|
|
616
631
|
// change isn't inserted twice (Yjs + setContent → duplicated region).
|
|
617
632
|
const apply = (deferred = false) => {
|
|
618
633
|
if (cancelled || editor.isDestroyed) return;
|
|
619
|
-
if (
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
634
|
+
if (contentUpdatedAt) {
|
|
635
|
+
const rollback = acknowledgementBaseRollbackRef.current;
|
|
636
|
+
const conflictsWithAcceptedAcknowledgement =
|
|
637
|
+
contentRevision &&
|
|
638
|
+
rollback?.updatedAt === contentUpdatedAt &&
|
|
639
|
+
rollback.acknowledgementRevision !== contentRevision;
|
|
640
|
+
if (conflictsWithAcceptedAcknowledgement) {
|
|
641
|
+
const order = compareContentRevisions?.(
|
|
642
|
+
contentRevision,
|
|
643
|
+
rollback.acknowledgementRevision,
|
|
644
|
+
);
|
|
645
|
+
if (order !== undefined && order !== null && order <= 0) {
|
|
646
|
+
return;
|
|
647
|
+
}
|
|
648
|
+
// The acknowledgement advanced our base before its SQL snapshot was
|
|
649
|
+
// observed, but another revision won at the same timestamp. Restore
|
|
650
|
+
// the common base and treat this first canonical winner as the
|
|
651
|
+
// authoritative revision for that otherwise unordered timestamp.
|
|
652
|
+
if (
|
|
653
|
+
authoritativeBaseRef.current?.revision ===
|
|
654
|
+
rollback.acknowledgementRevision
|
|
655
|
+
) {
|
|
656
|
+
authoritativeBaseRef.current = rollback.base;
|
|
657
|
+
}
|
|
658
|
+
acknowledgementBaseRollbackRef.current = null;
|
|
659
|
+
latestObservedUpdatedAtRef.current = contentUpdatedAt;
|
|
660
|
+
latestObservedRevisionRef.current = contentRevision;
|
|
661
|
+
} else if (
|
|
662
|
+
!latestObservedUpdatedAtRef.current ||
|
|
663
|
+
contentUpdatedAt > latestObservedUpdatedAtRef.current
|
|
664
|
+
) {
|
|
665
|
+
latestObservedUpdatedAtRef.current = contentUpdatedAt;
|
|
666
|
+
latestObservedRevisionRef.current = contentRevision ?? null;
|
|
667
|
+
acknowledgementBaseRollbackRef.current = null;
|
|
668
|
+
} else if (
|
|
669
|
+
contentUpdatedAt === latestObservedUpdatedAtRef.current &&
|
|
670
|
+
contentRevision &&
|
|
671
|
+
latestObservedRevisionRef.current !== contentRevision
|
|
672
|
+
) {
|
|
673
|
+
const order = latestObservedRevisionRef.current
|
|
674
|
+
? compareContentRevisions?.(
|
|
675
|
+
contentRevision,
|
|
676
|
+
latestObservedRevisionRef.current,
|
|
677
|
+
)
|
|
678
|
+
: null;
|
|
679
|
+
if (order !== undefined && order !== null && order <= 0) {
|
|
680
|
+
return;
|
|
681
|
+
}
|
|
682
|
+
// Legacy opaque revisions cannot be ordered. Preserve their existing
|
|
683
|
+
// reconcile behavior; ordered body revisions retain the newest
|
|
684
|
+
// identity so a delayed snapshot cannot roll it back.
|
|
685
|
+
latestObservedRevisionRef.current =
|
|
686
|
+
order !== undefined && order !== null ? contentRevision : null;
|
|
687
|
+
}
|
|
625
688
|
}
|
|
689
|
+
let rejectedMatchingAcknowledgement = false;
|
|
626
690
|
if (acknowledgedLocalSnapshot) {
|
|
627
691
|
const acceptedAcknowledgement = acknowledgedLocalSnapshotRef.current;
|
|
628
692
|
const acknowledgementIsNewestAccepted =
|
|
629
693
|
!acceptedAcknowledgement ||
|
|
630
694
|
acknowledgedLocalSnapshot.sequence > acceptedAcknowledgement.sequence;
|
|
695
|
+
const acknowledgementRevisionOrder =
|
|
696
|
+
acknowledgedLocalSnapshot.updatedAt ===
|
|
697
|
+
latestObservedUpdatedAtRef.current &&
|
|
698
|
+
latestObservedRevisionRef.current
|
|
699
|
+
? compareContentRevisions?.(
|
|
700
|
+
acknowledgedLocalSnapshot.revision,
|
|
701
|
+
latestObservedRevisionRef.current,
|
|
702
|
+
)
|
|
703
|
+
: null;
|
|
631
704
|
const acknowledgementIsNotSuperseded =
|
|
632
705
|
!latestObservedUpdatedAtRef.current ||
|
|
633
|
-
acknowledgedLocalSnapshot.updatedAt
|
|
634
|
-
latestObservedUpdatedAtRef.current
|
|
706
|
+
acknowledgedLocalSnapshot.updatedAt >
|
|
707
|
+
latestObservedUpdatedAtRef.current ||
|
|
708
|
+
(acknowledgedLocalSnapshot.updatedAt ===
|
|
709
|
+
latestObservedUpdatedAtRef.current &&
|
|
710
|
+
(latestObservedRevisionRef.current ===
|
|
711
|
+
acknowledgedLocalSnapshot.revision ||
|
|
712
|
+
acknowledgementRevisionOrder === undefined ||
|
|
713
|
+
acknowledgementRevisionOrder === null ||
|
|
714
|
+
acknowledgementRevisionOrder >= 0));
|
|
635
715
|
if (acknowledgementIsNewestAccepted && acknowledgementIsNotSuperseded) {
|
|
636
716
|
acknowledgedLocalSnapshotRef.current = acknowledgedLocalSnapshot;
|
|
717
|
+
const existingRollback = acknowledgementBaseRollbackRef.current;
|
|
718
|
+
if (
|
|
719
|
+
existingRollback?.acknowledgementRevision !==
|
|
720
|
+
acknowledgedLocalSnapshot.revision ||
|
|
721
|
+
existingRollback.updatedAt !== acknowledgedLocalSnapshot.updatedAt
|
|
722
|
+
) {
|
|
723
|
+
acknowledgementBaseRollbackRef.current = {
|
|
724
|
+
acknowledgementRevision: acknowledgedLocalSnapshot.revision,
|
|
725
|
+
updatedAt: acknowledgedLocalSnapshot.updatedAt,
|
|
726
|
+
base: authoritativeBaseRef.current,
|
|
727
|
+
};
|
|
728
|
+
}
|
|
637
729
|
authoritativeBaseRef.current = {
|
|
638
730
|
value: acknowledgedLocalSnapshot.value,
|
|
639
731
|
revision: acknowledgedLocalSnapshot.revision,
|
|
640
732
|
};
|
|
733
|
+
if (
|
|
734
|
+
acknowledgedLocalSnapshot.updatedAt ===
|
|
735
|
+
latestObservedUpdatedAtRef.current &&
|
|
736
|
+
acknowledgementRevisionOrder !== undefined &&
|
|
737
|
+
acknowledgementRevisionOrder !== null &&
|
|
738
|
+
acknowledgementRevisionOrder > 0
|
|
739
|
+
) {
|
|
740
|
+
latestObservedRevisionRef.current =
|
|
741
|
+
acknowledgedLocalSnapshot.revision;
|
|
742
|
+
}
|
|
641
743
|
if (
|
|
642
744
|
!lastAppliedUpdatedAtRef.current ||
|
|
643
745
|
acknowledgedLocalSnapshot.updatedAt >
|
|
@@ -646,8 +748,14 @@ export function useCollabReconcile({
|
|
|
646
748
|
lastAppliedUpdatedAtRef.current =
|
|
647
749
|
acknowledgedLocalSnapshot.updatedAt;
|
|
648
750
|
}
|
|
751
|
+
} else if (
|
|
752
|
+
contentRevision === acknowledgedLocalSnapshot.revision &&
|
|
753
|
+
value === acknowledgedLocalSnapshot.value
|
|
754
|
+
) {
|
|
755
|
+
rejectedMatchingAcknowledgement = true;
|
|
649
756
|
}
|
|
650
757
|
}
|
|
758
|
+
if (rejectedMatchingAcknowledgement) return;
|
|
651
759
|
const acknowledged = acknowledgedLocalSnapshotRef.current;
|
|
652
760
|
if (
|
|
653
761
|
acknowledged &&
|
|
@@ -656,7 +764,9 @@ export function useCollabReconcile({
|
|
|
656
764
|
) {
|
|
657
765
|
const acknowledgementWasSuperseded =
|
|
658
766
|
latestObservedUpdatedAtRef.current !== null &&
|
|
659
|
-
acknowledged.updatedAt < latestObservedUpdatedAtRef.current
|
|
767
|
+
(acknowledged.updatedAt < latestObservedUpdatedAtRef.current ||
|
|
768
|
+
(acknowledged.updatedAt === latestObservedUpdatedAtRef.current &&
|
|
769
|
+
latestObservedRevisionRef.current !== acknowledged.revision));
|
|
660
770
|
if (!acknowledgementWasSuperseded) {
|
|
661
771
|
authoritativeBaseRef.current = {
|
|
662
772
|
value: acknowledged.value,
|
|
@@ -990,6 +1100,7 @@ export function useCollabReconcile({
|
|
|
990
1100
|
}, [
|
|
991
1101
|
contentUpdatedAt,
|
|
992
1102
|
contentRevision,
|
|
1103
|
+
compareContentRevisions,
|
|
993
1104
|
acknowledgedLocalSnapshot,
|
|
994
1105
|
editor,
|
|
995
1106
|
ydoc,
|
package/src/provider.tsx
CHANGED
|
@@ -65,6 +65,7 @@ export function ToolkitProvider({
|
|
|
65
65
|
children,
|
|
66
66
|
intent,
|
|
67
67
|
emphasis,
|
|
68
|
+
inset,
|
|
68
69
|
size,
|
|
69
70
|
pending,
|
|
70
71
|
leadingIcon,
|
|
@@ -75,15 +76,17 @@ export function ToolkitProvider({
|
|
|
75
76
|
...props
|
|
76
77
|
}: ActionButtonProps) {
|
|
77
78
|
const variant =
|
|
78
|
-
emphasis === "ghost"
|
|
79
|
-
? "ghost"
|
|
80
|
-
: emphasis === "
|
|
81
|
-
? "
|
|
82
|
-
:
|
|
83
|
-
? "
|
|
84
|
-
: intent === "
|
|
85
|
-
? "
|
|
86
|
-
: "
|
|
79
|
+
inset && emphasis === "ghost"
|
|
80
|
+
? "ghost-inset"
|
|
81
|
+
: emphasis === "ghost"
|
|
82
|
+
? "ghost"
|
|
83
|
+
: emphasis === "outline"
|
|
84
|
+
? "outline"
|
|
85
|
+
: intent === "danger"
|
|
86
|
+
? "destructive"
|
|
87
|
+
: intent === "primary"
|
|
88
|
+
? "default"
|
|
89
|
+
: "secondary";
|
|
87
90
|
const legacySize =
|
|
88
91
|
size === "compact" ? "sm" : size === "large" ? "lg" : "default";
|
|
89
92
|
return (
|
package/src/ui/button.tsx
CHANGED
|
@@ -14,6 +14,8 @@ import type {
|
|
|
14
14
|
import { useToolkitComponent } from "../provider.js";
|
|
15
15
|
import { cn } from "../utils.js";
|
|
16
16
|
|
|
17
|
+
export type ButtonEmphasis = DesignSystemEmphasis | "ghost-inset";
|
|
18
|
+
|
|
17
19
|
const buttonVariants = cva(
|
|
18
20
|
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium ring-offset-background transition-[color,background-color,border-color,box-shadow,transform,scale] duration-150 active:scale-[0.98] motion-reduce:active:scale-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0",
|
|
19
21
|
{
|
|
@@ -26,6 +28,8 @@ const buttonVariants = cva(
|
|
|
26
28
|
secondary:
|
|
27
29
|
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
|
|
28
30
|
ghost: "hover:bg-accent hover:text-accent-foreground",
|
|
31
|
+
"ghost-inset":
|
|
32
|
+
"text-muted-foreground hover:bg-accent/40 hover:text-foreground focus-visible:ring-inset focus-visible:ring-offset-0",
|
|
29
33
|
link: "text-primary underline-offset-4 hover:underline",
|
|
30
34
|
},
|
|
31
35
|
size: {
|
|
@@ -50,7 +54,7 @@ export interface ButtonProps
|
|
|
50
54
|
/** Semantic meaning forwarded to a registered design-system ActionButton. */
|
|
51
55
|
intent?: DesignSystemIntent;
|
|
52
56
|
/** Semantic prominence forwarded independently of the default visual variant. */
|
|
53
|
-
emphasis?:
|
|
57
|
+
emphasis?: ButtonEmphasis;
|
|
54
58
|
}
|
|
55
59
|
|
|
56
60
|
const ButtonBase = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
@@ -104,13 +108,21 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
104
108
|
: variant === "default"
|
|
105
109
|
? "primary"
|
|
106
110
|
: "neutral");
|
|
107
|
-
const semanticEmphasis =
|
|
108
|
-
emphasis
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
const semanticEmphasis: DesignSystemEmphasis =
|
|
112
|
+
emphasis === "ghost-inset"
|
|
113
|
+
? "ghost"
|
|
114
|
+
: (emphasis ??
|
|
115
|
+
(variant === "outline"
|
|
116
|
+
? "outline"
|
|
117
|
+
: variant === "ghost-inset"
|
|
118
|
+
? "ghost"
|
|
119
|
+
: variant === "ghost" || variant === "link"
|
|
120
|
+
? "ghost"
|
|
121
|
+
: "solid"));
|
|
122
|
+
const inset =
|
|
123
|
+
semanticEmphasis === "ghost" &&
|
|
124
|
+
(emphasis === "ghost-inset" ||
|
|
125
|
+
(!emphasis && variant === "ghost-inset"));
|
|
114
126
|
const semanticSize =
|
|
115
127
|
size === "sm" ? "compact" : size === "lg" ? "large" : "default";
|
|
116
128
|
return (
|
|
@@ -128,6 +140,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
|
128
140
|
disabled={props.disabled}
|
|
129
141
|
intent={semanticIntent}
|
|
130
142
|
emphasis={semanticEmphasis}
|
|
143
|
+
inset={inset || undefined}
|
|
131
144
|
size={semanticSize}
|
|
132
145
|
onPress={(event) =>
|
|
133
146
|
props.onClick?.(
|