canvu-react 0.4.80 → 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");
@@ -724,9 +760,11 @@ function applyLocalItemsToYDoc(board, options) {
724
760
  const toDelete = [];
725
761
  for (const [id, entry] of currentIndex) {
726
762
  if (nextIds.has(id)) continue;
727
- const serverSeenAt = board.serverItemSeenAt.get(id);
728
- if (serverSeenAt != null && now - serverSeenAt < SERVER_ADD_RACE_WINDOW_MS) {
729
- 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
+ }
730
768
  }
731
769
  toDelete.push({ id, index: entry.index });
732
770
  }
@@ -734,6 +772,7 @@ function applyLocalItemsToYDoc(board, options) {
734
772
  for (const { id, index } of toDelete) {
735
773
  board.yItems.delete(index, 1);
736
774
  board.serverItemSeenAt.delete(id);
775
+ board.locallyDeletedItemIds.set(id, now);
737
776
  removedIds.push(id);
738
777
  }
739
778
  const refreshedIndex = indexYItemsById(board.yItems);
@@ -742,6 +781,9 @@ function applyLocalItemsToYDoc(board, options) {
742
781
  if (!item) continue;
743
782
  const id = getItemId(item);
744
783
  if (!id) continue;
784
+ board.consumerSeenItemIds.add(id);
785
+ if (board.serverDeletedItemIds.has(id)) continue;
786
+ board.locallyDeletedItemIds.delete(id);
745
787
  const existing = refreshedIndex.get(id);
746
788
  if (existing) {
747
789
  updateYMapInPlace(existing.yMap, item);
@@ -756,12 +798,33 @@ function applyLocalItemsToYDoc(board, options) {
756
798
  return { addedIds, removedIds };
757
799
  }
758
800
  function applyServerSnapshotToYDoc(board, options) {
759
- const { items: snapshotItems, origin } = options;
801
+ const { items: snapshotItems, deletedItemIds, origin } = options;
760
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
+ }
761
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
+ }
762
821
  for (const item of snapshotItems) {
763
822
  const id = getItemId(item);
764
823
  if (!id) continue;
824
+ if (board.serverDeletedItemIds.has(id)) continue;
825
+ if (isLocalDeleteTombstoneActive(board, id, now)) {
826
+ continue;
827
+ }
765
828
  const existing = indexYItemsById(board.yItems).get(id);
766
829
  if (existing) {
767
830
  const currentSerialized = serializeItem(existing.yMap);
@@ -792,7 +855,7 @@ function applyServerSnapshotToYDoc(board, options) {
792
855
  }
793
856
  }
794
857
  function replaceYDocWithSnapshot(board, options) {
795
- const { items: snapshotItems, origin } = options;
858
+ const { items: snapshotItems, deletedItemIds, origin } = options;
796
859
  const now = Date.now();
797
860
  board.doc.transact(() => {
798
861
  if (board.yItems.length > 0) {
@@ -805,6 +868,12 @@ function replaceYDocWithSnapshot(board, options) {
805
868
  board.lastServerConfirmedIds = /* @__PURE__ */ new Set();
806
869
  board.lastServerConfirmedItemSerializations = /* @__PURE__ */ new Map();
807
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
+ }
808
877
  for (const item of snapshotItems) {
809
878
  const id = getItemId(item);
810
879
  if (id) {
@@ -1251,7 +1320,7 @@ function useRealtimeSession(options) {
1251
1320
  const applyDocument = useCallback(
1252
1321
  (snapshot, options2) => {
1253
1322
  const currentSnapshot = latestDocumentRef.current;
1254
- 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)) {
1255
1324
  return;
1256
1325
  }
1257
1326
  const board = boardRef.current;
@@ -1270,11 +1339,13 @@ function useRealtimeSession(options) {
1270
1339
  if (options2?.replace) {
1271
1340
  replaceYDocWithSnapshot(board, {
1272
1341
  items: snapshot.items,
1342
+ deletedItemIds: snapshot.deletedItemIds,
1273
1343
  origin: ORIGIN_REMOTE
1274
1344
  });
1275
1345
  } else {
1276
1346
  applyServerSnapshotToYDoc(board, {
1277
1347
  items: snapshot.items,
1348
+ deletedItemIds: snapshot.deletedItemIds,
1278
1349
  origin: ORIGIN_REMOTE
1279
1350
  });
1280
1351
  }
@@ -1282,7 +1353,11 @@ function useRealtimeSession(options) {
1282
1353
  const mergedItems = board ? readVectorItems(board.yItems) : snapshot.items;
1283
1354
  const mergedSnapshot = {
1284
1355
  ...snapshot,
1285
- 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)) } : {}
1286
1361
  };
1287
1362
  currentRevisionRef.current = snapshot.revision;
1288
1363
  latestDocumentRef.current = mergedSnapshot;