canvu-react 0.4.79 → 0.4.81

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.
@@ -19,6 +19,11 @@ type RealtimeDocumentSnapshot = {
19
19
  readonly updatedAt: number;
20
20
  readonly updatedByClientId?: string;
21
21
  readonly persistedRevision?: number;
22
+ /**
23
+ * Tombstones for items deleted in the room. Sent by servers that support
24
+ * explicit delete propagation; absent on older servers.
25
+ */
26
+ readonly deletedItemIds?: readonly string[];
22
27
  };
23
28
  type RealtimeConnectionInfo = {
24
29
  readonly state: RealtimeConnectionState;
@@ -19,6 +19,11 @@ type RealtimeDocumentSnapshot = {
19
19
  readonly updatedAt: number;
20
20
  readonly updatedByClientId?: string;
21
21
  readonly persistedRevision?: number;
22
+ /**
23
+ * Tombstones for items deleted in the room. Sent by servers that support
24
+ * explicit delete propagation; absent on older servers.
25
+ */
26
+ readonly deletedItemIds?: readonly string[];
22
27
  };
23
28
  type RealtimeConnectionInfo = {
24
29
  readonly state: RealtimeConnectionState;
@@ -82,12 +82,16 @@ function parseDocumentSnapshot(value) {
82
82
  const updatedAt = getNumber(value.updatedAt);
83
83
  const items = parseItems(value.items);
84
84
  if (revision == null || updatedAt == null || items == null) return void 0;
85
+ const deletedItemIds = Array.isArray(value.deletedItemIds) ? value.deletedItemIds.filter(
86
+ (entry) => typeof entry === "string"
87
+ ) : void 0;
85
88
  return {
86
89
  revision,
87
90
  updatedAt,
88
91
  items,
89
92
  ...getString(value.updatedByClientId) ? { updatedByClientId: getString(value.updatedByClientId) } : {},
90
- ...getNumber(value.persistedRevision) != null ? { persistedRevision: getNumber(value.persistedRevision) } : {}
93
+ ...getNumber(value.persistedRevision) != null ? { persistedRevision: getNumber(value.persistedRevision) } : {},
94
+ ...deletedItemIds ? { deletedItemIds } : {}
91
95
  };
92
96
  }
93
97
  function parseRealtimeSessionPeer(value) {
@@ -324,6 +328,7 @@ function useRealtimeCanvasDocument(options) {
324
328
  const realtimeEnabled = enabled && session != null;
325
329
  const documentRevision = session?.document?.revision ?? null;
326
330
  const documentItems = session?.document?.items;
331
+ const documentDeletedItemIds = session?.document?.deletedItemIds;
327
332
  const documentUpdatedByClientId = session?.document?.updatedByClientId ?? null;
328
333
  const connectionClientId = session?.connection.clientId ?? null;
329
334
  const hasLocalOfflineDraft = session?.hasLocalOfflineDraft ?? false;
@@ -373,15 +378,22 @@ function useRealtimeCanvasDocument(options) {
373
378
  if (cancelled) return;
374
379
  if (inFlightRevisionRef.current !== documentRevision) return;
375
380
  lastAppliedRevisionRef.current = documentRevision;
376
- const localItems = latestItemsRef.current;
381
+ const rawLocalItems = latestItemsRef.current;
382
+ const deletedIds = new Set(documentDeletedItemIds ?? []);
383
+ const localItems = deletedIds.size > 0 ? rawLocalItems.filter((item) => {
384
+ const id = getSceneItemId(item);
385
+ return !id || !deletedIds.has(id);
386
+ }) : rawLocalItems;
377
387
  const hasLocalItems = localItems.length > 0;
378
388
  const hasPendingLocalChanges = hasLocalOfflineDraft || hasPendingDocumentSync || hasLocalChangeInFlightRef.current;
379
389
  if (resolvedItems.length === 0 && (hasEverPropagatedItemsRef.current || hasLocalItems || hasPendingLocalChanges)) {
380
390
  if (hasLocalItems) {
381
391
  const normalizedLocalItems = normalizeItems ? normalizeItems(localItems) : [...localItems];
382
392
  session.remoteAdapter.send?.(normalizedLocalItems);
393
+ return;
383
394
  }
384
- return;
395
+ const emptinessExplainedByDeletes = rawLocalItems.length > 0 && localItems.length === 0;
396
+ if (!emptinessExplainedByDeletes) return;
385
397
  }
386
398
  if (shouldPreserveLocalRealtimeItems({
387
399
  localItems,
@@ -409,6 +421,7 @@ function useRealtimeCanvasDocument(options) {
409
421
  }, [
410
422
  applyIncomingItems,
411
423
  connectionClientId,
424
+ documentDeletedItemIds,
412
425
  documentItems,
413
426
  documentRevision,
414
427
  documentUpdatedByClientId,
@@ -585,6 +598,16 @@ var CLIENT_ONLY_IMAGE_KEYS2 = /* @__PURE__ */ new Set([
585
598
  "imageThumbnailHref"
586
599
  ]);
587
600
  var SERVER_ADD_RACE_WINDOW_MS = 2e3;
601
+ var LOCAL_DELETE_TOMBSTONE_TTL_MS = 1e4;
602
+ function isLocalDeleteTombstoneActive(board, id, now) {
603
+ const deletedAt = board.locallyDeletedItemIds.get(id);
604
+ if (deletedAt == null) return false;
605
+ if (now - deletedAt >= LOCAL_DELETE_TOMBSTONE_TTL_MS) {
606
+ board.locallyDeletedItemIds.delete(id);
607
+ return false;
608
+ }
609
+ return true;
610
+ }
588
611
  function createYjsBoardDoc() {
589
612
  const doc = new Y.Doc();
590
613
  const yItems = doc.getArray(ITEMS_KEY);
@@ -593,9 +616,22 @@ function createYjsBoardDoc() {
593
616
  yItems,
594
617
  lastServerConfirmedIds: /* @__PURE__ */ new Set(),
595
618
  lastServerConfirmedItemSerializations: /* @__PURE__ */ new Map(),
596
- serverItemSeenAt: /* @__PURE__ */ new Map()
619
+ serverItemSeenAt: /* @__PURE__ */ new Map(),
620
+ serverDeletedItemIds: /* @__PURE__ */ new Set(),
621
+ locallyDeletedItemIds: /* @__PURE__ */ new Map(),
622
+ consumerSeenItemIds: /* @__PURE__ */ new Set()
597
623
  };
598
624
  }
625
+ function getRealtimeDeletedItemIds(board) {
626
+ const now = Date.now();
627
+ const result = new Set(board.serverDeletedItemIds);
628
+ for (const id of Array.from(board.locallyDeletedItemIds.keys())) {
629
+ if (isLocalDeleteTombstoneActive(board, id, now)) {
630
+ result.add(id);
631
+ }
632
+ }
633
+ return result;
634
+ }
599
635
  function getItemId(item) {
600
636
  if (item instanceof Y.Map) {
601
637
  const id2 = item.get("id");
@@ -676,6 +712,38 @@ function normalizeRealtimeItem(item) {
676
712
  normalized.childrenSvg = "";
677
713
  return normalized;
678
714
  }
715
+ function reorderYItemsToMatchLocalItems(yItems, items) {
716
+ const currentItems = readVectorItems(yItems);
717
+ const currentItemsById = /* @__PURE__ */ new Map();
718
+ for (const currentItem of currentItems) {
719
+ const id = getItemId(currentItem);
720
+ if (id) currentItemsById.set(id, currentItem);
721
+ }
722
+ const orderedItems = [];
723
+ const orderedIds = /* @__PURE__ */ new Set();
724
+ for (const item of items) {
725
+ const id = getItemId(item);
726
+ if (!id) continue;
727
+ const currentItem = currentItemsById.get(id);
728
+ if (!currentItem) continue;
729
+ orderedItems.push(currentItem);
730
+ orderedIds.add(id);
731
+ }
732
+ for (const currentItem of currentItems) {
733
+ const id = getItemId(currentItem);
734
+ if (!id || !orderedIds.has(id)) orderedItems.push(currentItem);
735
+ }
736
+ const alreadyOrdered = currentItems.length === orderedItems.length && currentItems.every((currentItem, index) => {
737
+ const nextItem = orderedItems[index];
738
+ return nextItem ? getItemId(currentItem) === getItemId(nextItem) : false;
739
+ });
740
+ if (alreadyOrdered) return;
741
+ if (yItems.length > 0) yItems.delete(0, yItems.length);
742
+ yItems.insert(
743
+ 0,
744
+ orderedItems.map((item) => vectorItemToYMap(item))
745
+ );
746
+ }
679
747
  function applyLocalItemsToYDoc(board, options) {
680
748
  const { items, origin } = options;
681
749
  const addedIds = [];
@@ -692,9 +760,11 @@ function applyLocalItemsToYDoc(board, options) {
692
760
  const toDelete = [];
693
761
  for (const [id, entry] of currentIndex) {
694
762
  if (nextIds.has(id)) continue;
695
- const serverSeenAt = board.serverItemSeenAt.get(id);
696
- if (serverSeenAt != null && now - serverSeenAt < SERVER_ADD_RACE_WINDOW_MS) {
697
- continue;
763
+ if (!board.consumerSeenItemIds.has(id)) {
764
+ const serverSeenAt = board.serverItemSeenAt.get(id);
765
+ if (serverSeenAt != null && now - serverSeenAt < SERVER_ADD_RACE_WINDOW_MS) {
766
+ continue;
767
+ }
698
768
  }
699
769
  toDelete.push({ id, index: entry.index });
700
770
  }
@@ -702,6 +772,7 @@ function applyLocalItemsToYDoc(board, options) {
702
772
  for (const { id, index } of toDelete) {
703
773
  board.yItems.delete(index, 1);
704
774
  board.serverItemSeenAt.delete(id);
775
+ board.locallyDeletedItemIds.set(id, now);
705
776
  removedIds.push(id);
706
777
  }
707
778
  const refreshedIndex = indexYItemsById(board.yItems);
@@ -710,6 +781,9 @@ function applyLocalItemsToYDoc(board, options) {
710
781
  if (!item) continue;
711
782
  const id = getItemId(item);
712
783
  if (!id) continue;
784
+ board.consumerSeenItemIds.add(id);
785
+ if (board.serverDeletedItemIds.has(id)) continue;
786
+ board.locallyDeletedItemIds.delete(id);
713
787
  const existing = refreshedIndex.get(id);
714
788
  if (existing) {
715
789
  updateYMapInPlace(existing.yMap, item);
@@ -719,16 +793,38 @@ function applyLocalItemsToYDoc(board, options) {
719
793
  board.yItems.push([yMap]);
720
794
  addedIds.push(id);
721
795
  }
796
+ reorderYItemsToMatchLocalItems(board.yItems, items);
722
797
  }, origin);
723
798
  return { addedIds, removedIds };
724
799
  }
725
800
  function applyServerSnapshotToYDoc(board, options) {
726
- const { items: snapshotItems, origin } = options;
801
+ const { items: snapshotItems, deletedItemIds, origin } = options;
727
802
  const now = Date.now();
803
+ const snapshotItemIds = /* @__PURE__ */ new Set();
804
+ for (const item of snapshotItems) {
805
+ const id = getItemId(item);
806
+ if (id) snapshotItemIds.add(id);
807
+ }
728
808
  board.doc.transact(() => {
809
+ if (deletedItemIds) {
810
+ for (const id of deletedItemIds) {
811
+ if (snapshotItemIds.has(id)) continue;
812
+ board.serverDeletedItemIds.add(id);
813
+ board.locallyDeletedItemIds.delete(id);
814
+ board.serverItemSeenAt.delete(id);
815
+ const existing = indexYItemsById(board.yItems).get(id);
816
+ if (existing) {
817
+ board.yItems.delete(existing.index, 1);
818
+ }
819
+ }
820
+ }
729
821
  for (const item of snapshotItems) {
730
822
  const id = getItemId(item);
731
823
  if (!id) continue;
824
+ if (board.serverDeletedItemIds.has(id)) continue;
825
+ if (isLocalDeleteTombstoneActive(board, id, now)) {
826
+ continue;
827
+ }
732
828
  const existing = indexYItemsById(board.yItems).get(id);
733
829
  if (existing) {
734
830
  const currentSerialized = serializeItem(existing.yMap);
@@ -759,7 +855,7 @@ function applyServerSnapshotToYDoc(board, options) {
759
855
  }
760
856
  }
761
857
  function replaceYDocWithSnapshot(board, options) {
762
- const { items: snapshotItems, origin } = options;
858
+ const { items: snapshotItems, deletedItemIds, origin } = options;
763
859
  const now = Date.now();
764
860
  board.doc.transact(() => {
765
861
  if (board.yItems.length > 0) {
@@ -772,6 +868,12 @@ function replaceYDocWithSnapshot(board, options) {
772
868
  board.lastServerConfirmedIds = /* @__PURE__ */ new Set();
773
869
  board.lastServerConfirmedItemSerializations = /* @__PURE__ */ new Map();
774
870
  board.serverItemSeenAt = /* @__PURE__ */ new Map();
871
+ board.locallyDeletedItemIds = /* @__PURE__ */ new Map();
872
+ if (deletedItemIds) {
873
+ for (const id of deletedItemIds) {
874
+ board.serverDeletedItemIds.add(id);
875
+ }
876
+ }
775
877
  for (const item of snapshotItems) {
776
878
  const id = getItemId(item);
777
879
  if (id) {
@@ -1218,7 +1320,7 @@ function useRealtimeSession(options) {
1218
1320
  const applyDocument = useCallback(
1219
1321
  (snapshot, options2) => {
1220
1322
  const currentSnapshot = latestDocumentRef.current;
1221
- if (currentSnapshot && currentSnapshot.revision === snapshot.revision && currentSnapshot.updatedByClientId === snapshot.updatedByClientId && currentSnapshot.persistedRevision === snapshot.persistedRevision && sameSerializedItems(currentSnapshot.items, snapshot.items)) {
1323
+ if (currentSnapshot && currentSnapshot.revision === snapshot.revision && currentSnapshot.updatedByClientId === snapshot.updatedByClientId && currentSnapshot.persistedRevision === snapshot.persistedRevision && (currentSnapshot.deletedItemIds?.length ?? 0) === (snapshot.deletedItemIds?.length ?? 0) && sameSerializedItems(currentSnapshot.items, snapshot.items)) {
1222
1324
  return;
1223
1325
  }
1224
1326
  const board = boardRef.current;
@@ -1237,11 +1339,13 @@ function useRealtimeSession(options) {
1237
1339
  if (options2?.replace) {
1238
1340
  replaceYDocWithSnapshot(board, {
1239
1341
  items: snapshot.items,
1342
+ deletedItemIds: snapshot.deletedItemIds,
1240
1343
  origin: ORIGIN_REMOTE
1241
1344
  });
1242
1345
  } else {
1243
1346
  applyServerSnapshotToYDoc(board, {
1244
1347
  items: snapshot.items,
1348
+ deletedItemIds: snapshot.deletedItemIds,
1245
1349
  origin: ORIGIN_REMOTE
1246
1350
  });
1247
1351
  }
@@ -1249,7 +1353,11 @@ function useRealtimeSession(options) {
1249
1353
  const mergedItems = board ? readVectorItems(board.yItems) : snapshot.items;
1250
1354
  const mergedSnapshot = {
1251
1355
  ...snapshot,
1252
- items: mergedItems
1356
+ items: mergedItems,
1357
+ // Expose the accumulated tombstones (server-confirmed + pending
1358
+ // local deletes) so document consumers can prune stale local
1359
+ // views instead of re-sending deleted items.
1360
+ ...board ? { deletedItemIds: Array.from(getRealtimeDeletedItemIds(board)) } : {}
1253
1361
  };
1254
1362
  currentRevisionRef.current = snapshot.revision;
1255
1363
  latestDocumentRef.current = mergedSnapshot;