canvu-react 0.3.40 → 0.4.1

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/realtime.cjs CHANGED
@@ -4,10 +4,30 @@ var lucideReact = require('lucide-react');
4
4
  var getStroke = require('perfect-freehand');
5
5
  var jsxRuntime = require('react/jsx-runtime');
6
6
  var react = require('react');
7
+ var Y = require('yjs');
7
8
 
8
9
  function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
9
10
 
11
+ function _interopNamespace(e) {
12
+ if (e && e.__esModule) return e;
13
+ var n = Object.create(null);
14
+ if (e) {
15
+ Object.keys(e).forEach(function (k) {
16
+ if (k !== 'default') {
17
+ var d = Object.getOwnPropertyDescriptor(e, k);
18
+ Object.defineProperty(n, k, d.get ? d : {
19
+ enumerable: true,
20
+ get: function () { return e[k]; }
21
+ });
22
+ }
23
+ });
24
+ }
25
+ n.default = e;
26
+ return Object.freeze(n);
27
+ }
28
+
10
29
  var getStroke__default = /*#__PURE__*/_interopDefault(getStroke);
30
+ var Y__namespace = /*#__PURE__*/_interopNamespace(Y);
11
31
 
12
32
  // src/react/presence/map-placement-preview.ts
13
33
  function remoteMarkupStrokeFromPlacementPreview(preview) {
@@ -1992,8 +2012,215 @@ function useRealtimePeerFollow(options) {
1992
2012
  lastAppliedCameraKeyRef.current = nextCameraKey;
1993
2013
  }, [followedPeerId, onFollowEnd, sessionPeers, viewportRef]);
1994
2014
  }
2015
+ var ITEMS_KEY = "items";
2016
+ var SERVER_ADD_RACE_WINDOW_MS = 2e3;
2017
+ function createYjsBoardDoc() {
2018
+ const doc = new Y__namespace.Doc();
2019
+ const yItems = doc.getArray(ITEMS_KEY);
2020
+ return {
2021
+ doc,
2022
+ yItems,
2023
+ lastServerConfirmedIds: /* @__PURE__ */ new Set(),
2024
+ serverItemSeenAt: /* @__PURE__ */ new Map()
2025
+ };
2026
+ }
2027
+ function getItemId(item) {
2028
+ if (item instanceof Y__namespace.Map) {
2029
+ const id2 = item.get("id");
2030
+ return typeof id2 === "string" ? id2 : null;
2031
+ }
2032
+ const id = item.id;
2033
+ return typeof id === "string" ? id : null;
2034
+ }
2035
+ function vectorItemToYMap(item) {
2036
+ const yMap = new Y__namespace.Map();
2037
+ for (const [key, value] of Object.entries(item)) {
2038
+ if (value === void 0) continue;
2039
+ yMap.set(key, value);
2040
+ }
2041
+ return yMap;
2042
+ }
2043
+ function yMapToVectorItem(yMap) {
2044
+ const obj = {};
2045
+ for (const [key, value] of yMap.entries()) {
2046
+ obj[key] = value;
2047
+ }
2048
+ return obj;
2049
+ }
2050
+ function readVectorItems(yItems) {
2051
+ const result = [];
2052
+ for (let i = 0; i < yItems.length; i++) {
2053
+ const yMap = yItems.get(i);
2054
+ if (yMap) result.push(yMapToVectorItem(yMap));
2055
+ }
2056
+ return result;
2057
+ }
2058
+ function indexYItemsById(yItems) {
2059
+ const result = /* @__PURE__ */ new Map();
2060
+ for (let i = 0; i < yItems.length; i++) {
2061
+ const yMap = yItems.get(i);
2062
+ if (!yMap) continue;
2063
+ const id = getItemId(yMap);
2064
+ if (!id) continue;
2065
+ result.set(id, { yMap, index: i });
2066
+ }
2067
+ return result;
2068
+ }
2069
+ function valuesEqual(left, right) {
2070
+ if (left === right) return true;
2071
+ if (typeof left !== typeof right) return false;
2072
+ if (left === null || right === null) return false;
2073
+ if (typeof left !== "object") return false;
2074
+ try {
2075
+ return JSON.stringify(left) === JSON.stringify(right);
2076
+ } catch {
2077
+ return false;
2078
+ }
2079
+ }
2080
+ function updateYMapInPlace(yMap, next) {
2081
+ const nextKeys = /* @__PURE__ */ new Set();
2082
+ for (const [key, value] of Object.entries(next)) {
2083
+ if (value === void 0) continue;
2084
+ nextKeys.add(key);
2085
+ const current = yMap.get(key);
2086
+ if (!valuesEqual(current, value)) {
2087
+ yMap.set(key, value);
2088
+ }
2089
+ }
2090
+ for (const key of Array.from(yMap.keys())) {
2091
+ if (!nextKeys.has(key)) yMap.delete(key);
2092
+ }
2093
+ }
2094
+ function applyLocalItemsToYDoc(board, options) {
2095
+ const { items, origin } = options;
2096
+ const addedIds = [];
2097
+ const removedIds = [];
2098
+ const now = Date.now();
2099
+ board.doc.transact(() => {
2100
+ const currentIndex = indexYItemsById(board.yItems);
2101
+ const nextIds = /* @__PURE__ */ new Set();
2102
+ for (const item of items) {
2103
+ const id = getItemId(item);
2104
+ if (!id) continue;
2105
+ nextIds.add(id);
2106
+ }
2107
+ const toDelete = [];
2108
+ for (const [id, entry] of currentIndex) {
2109
+ if (nextIds.has(id)) continue;
2110
+ const serverSeenAt = board.serverItemSeenAt.get(id);
2111
+ if (serverSeenAt != null && now - serverSeenAt < SERVER_ADD_RACE_WINDOW_MS) {
2112
+ continue;
2113
+ }
2114
+ toDelete.push({ id, index: entry.index });
2115
+ }
2116
+ toDelete.sort((a, b) => b.index - a.index);
2117
+ for (const { id, index } of toDelete) {
2118
+ board.yItems.delete(index, 1);
2119
+ board.serverItemSeenAt.delete(id);
2120
+ removedIds.push(id);
2121
+ }
2122
+ const refreshedIndex = indexYItemsById(board.yItems);
2123
+ for (let nextOrder = 0; nextOrder < items.length; nextOrder++) {
2124
+ const item = items[nextOrder];
2125
+ if (!item) continue;
2126
+ const id = getItemId(item);
2127
+ if (!id) continue;
2128
+ const existing = refreshedIndex.get(id);
2129
+ if (existing) {
2130
+ updateYMapInPlace(existing.yMap, item);
2131
+ continue;
2132
+ }
2133
+ const yMap = vectorItemToYMap(item);
2134
+ board.yItems.push([yMap]);
2135
+ addedIds.push(id);
2136
+ }
2137
+ }, origin);
2138
+ return { addedIds, removedIds };
2139
+ }
2140
+ function applyServerSnapshotToYDoc(board, options) {
2141
+ const { items: snapshotItems, origin } = options;
2142
+ const now = Date.now();
2143
+ board.doc.transact(() => {
2144
+ for (const item of snapshotItems) {
2145
+ const id = getItemId(item);
2146
+ if (!id) continue;
2147
+ const existing = indexYItemsById(board.yItems).get(id);
2148
+ if (existing) {
2149
+ updateYMapInPlace(existing.yMap, item);
2150
+ board.serverItemSeenAt.set(id, now);
2151
+ continue;
2152
+ }
2153
+ const yMap = vectorItemToYMap(item);
2154
+ board.yItems.push([yMap]);
2155
+ board.serverItemSeenAt.set(id, now);
2156
+ }
2157
+ }, origin);
2158
+ board.lastServerConfirmedIds = /* @__PURE__ */ new Set();
2159
+ for (const item of snapshotItems) {
2160
+ const id = getItemId(item);
2161
+ if (id) board.lastServerConfirmedIds.add(id);
2162
+ }
2163
+ }
2164
+ function replaceYDocWithSnapshot(board, options) {
2165
+ const { items: snapshotItems, origin } = options;
2166
+ const now = Date.now();
2167
+ board.doc.transact(() => {
2168
+ if (board.yItems.length > 0) {
2169
+ board.yItems.delete(0, board.yItems.length);
2170
+ }
2171
+ for (const item of snapshotItems) {
2172
+ board.yItems.push([vectorItemToYMap(item)]);
2173
+ }
2174
+ }, origin);
2175
+ board.lastServerConfirmedIds = /* @__PURE__ */ new Set();
2176
+ board.serverItemSeenAt = /* @__PURE__ */ new Map();
2177
+ for (const item of snapshotItems) {
2178
+ const id = getItemId(item);
2179
+ if (id) {
2180
+ board.lastServerConfirmedIds.add(id);
2181
+ board.serverItemSeenAt.set(id, now);
2182
+ }
2183
+ }
2184
+ }
2185
+ function getLocallyPendingItemIds(board) {
2186
+ const pending = /* @__PURE__ */ new Set();
2187
+ for (let i = 0; i < board.yItems.length; i++) {
2188
+ const yMap = board.yItems.get(i);
2189
+ if (!yMap) continue;
2190
+ const id = getItemId(yMap);
2191
+ if (!id) continue;
2192
+ if (!board.lastServerConfirmedIds.has(id)) {
2193
+ pending.add(id);
2194
+ }
2195
+ }
2196
+ return pending;
2197
+ }
2198
+ function encodeYDocState(board) {
2199
+ const update = Y__namespace.encodeStateAsUpdate(board.doc);
2200
+ let binary = "";
2201
+ for (let i = 0; i < update.length; i++) {
2202
+ binary += String.fromCharCode(update[i]);
2203
+ }
2204
+ return btoa(binary);
2205
+ }
2206
+ function decodeYDocState(board, encoded) {
2207
+ try {
2208
+ const binary = atob(encoded);
2209
+ const update = new Uint8Array(binary.length);
2210
+ for (let i = 0; i < binary.length; i++) {
2211
+ update[i] = binary.charCodeAt(i);
2212
+ }
2213
+ Y__namespace.applyUpdate(board.doc, update);
2214
+ return true;
2215
+ } catch {
2216
+ return false;
2217
+ }
2218
+ }
1995
2219
 
1996
2220
  // src/react/plugins/realtime/use-realtime-session.ts
2221
+ var ORIGIN_LOCAL = /* @__PURE__ */ Symbol("canvu/realtime/local");
2222
+ var ORIGIN_REMOTE = /* @__PURE__ */ Symbol("canvu/realtime/remote");
2223
+ var ORIGIN_BOOTSTRAP = /* @__PURE__ */ Symbol("canvu/realtime/bootstrap");
1997
2224
  var DRAFT_STORAGE_PREFIX = "canvu-realtime-draft:";
1998
2225
  var DOCUMENT_FLUSH_DEBOUNCE_MS = 120;
1999
2226
  var DRAFT_PERSIST_DEBOUNCE_MS = 420;
@@ -2080,7 +2307,16 @@ function draftStorageKey(roomId) {
2080
2307
  function isRealtimeOfflineDraft(value) {
2081
2308
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
2082
2309
  const record = value;
2083
- return typeof record.roomId === "string" && typeof record.baseRevision === "number" && typeof record.updatedAt === "number" && Array.isArray(record.items);
2310
+ if (typeof record.roomId !== "string" || typeof record.baseRevision !== "number" || typeof record.updatedAt !== "number" || !Array.isArray(record.items)) {
2311
+ return false;
2312
+ }
2313
+ if (record.yDocState != null && typeof record.yDocState !== "string") {
2314
+ return false;
2315
+ }
2316
+ if (record.pendingIds != null && !Array.isArray(record.pendingIds)) {
2317
+ return false;
2318
+ }
2319
+ return true;
2084
2320
  }
2085
2321
  function readRealtimeOfflineDraft(roomId) {
2086
2322
  if (typeof window === "undefined" || !roomId) return null;
@@ -2118,14 +2354,6 @@ function removeRealtimeOfflineDraft(roomId) {
2118
2354
  } catch {
2119
2355
  }
2120
2356
  }
2121
- function buildDraftSnapshot(draft, clientId) {
2122
- return {
2123
- revision: draft.baseRevision,
2124
- items: draft.items,
2125
- updatedAt: draft.updatedAt,
2126
- updatedByClientId: clientId
2127
- };
2128
- }
2129
2357
  function getViewportCameraSnapshot(viewport) {
2130
2358
  if (!viewport) return null;
2131
2359
  const camera = viewport.getCamera();
@@ -2176,8 +2404,13 @@ function useRealtimeSession(options) {
2176
2404
  const retryCountRef = react.useRef(0);
2177
2405
  const currentRevisionRef = react.useRef(0);
2178
2406
  const outboundInFlightRef = react.useRef(null);
2179
- const queuedItemsRef = react.useRef(null);
2407
+ const queuedDirtyRef = react.useRef(false);
2408
+ const pendingLocalItemsRef = react.useRef(null);
2180
2409
  const subscriberRefs = react.useRef(/* @__PURE__ */ new Set());
2410
+ const boardRef = react.useRef(null);
2411
+ if (boardRef.current == null) {
2412
+ boardRef.current = createYjsBoardDoc();
2413
+ }
2181
2414
  const lastCursorRef = react.useRef(null);
2182
2415
  const lastMarkupStrokeRef = react.useRef(null);
2183
2416
  const lastCameraRef = react.useRef(
@@ -2272,11 +2505,30 @@ function useRealtimeSession(options) {
2272
2505
  if (currentSnapshot && currentSnapshot.revision === snapshot.revision && currentSnapshot.updatedByClientId === snapshot.updatedByClientId && sameSerializedItems(currentSnapshot.items, snapshot.items)) {
2273
2506
  return;
2274
2507
  }
2508
+ const board = boardRef.current;
2509
+ if (board) {
2510
+ if (options2?.replace) {
2511
+ replaceYDocWithSnapshot(board, {
2512
+ items: snapshot.items,
2513
+ origin: ORIGIN_REMOTE
2514
+ });
2515
+ } else {
2516
+ applyServerSnapshotToYDoc(board, {
2517
+ items: snapshot.items,
2518
+ origin: ORIGIN_REMOTE
2519
+ });
2520
+ }
2521
+ }
2522
+ const mergedItems = board ? readVectorItems(board.yItems) : snapshot.items;
2523
+ const mergedSnapshot = {
2524
+ ...snapshot,
2525
+ items: mergedItems
2526
+ };
2275
2527
  currentRevisionRef.current = snapshot.revision;
2276
- latestDocumentRef.current = snapshot;
2277
- setDocument(snapshot);
2528
+ latestDocumentRef.current = mergedSnapshot;
2529
+ setDocument(mergedSnapshot);
2278
2530
  if (!options2?.suppressSubscriberNotify) {
2279
- notifySubscribers(snapshot.items);
2531
+ notifySubscribers(mergedItems);
2280
2532
  }
2281
2533
  },
2282
2534
  [notifySubscribers]
@@ -2386,13 +2638,23 @@ function useRealtimeSession(options) {
2386
2638
  );
2387
2639
  const flushQueuedDocument = react.useCallback(() => {
2388
2640
  clearDocumentFlushSchedule();
2389
- if (conflictRef.current) return;
2390
- const next = queuedItemsRef.current;
2391
- if (!next || outboundInFlightRef.current) return;
2641
+ const board = boardRef.current;
2642
+ if (!board) return;
2643
+ if (!queuedDirtyRef.current && pendingLocalItemsRef.current == null) return;
2644
+ if (outboundInFlightRef.current) return;
2645
+ const pendingLocal = pendingLocalItemsRef.current;
2646
+ if (pendingLocal) {
2647
+ pendingLocalItemsRef.current = null;
2648
+ applyLocalItemsToYDoc(board, {
2649
+ items: pendingLocal,
2650
+ origin: ORIGIN_LOCAL
2651
+ });
2652
+ }
2392
2653
  const baseRevision = currentRevisionRef.current;
2393
- const preparedItems = prepareRealtimeItems(next);
2654
+ const mergedItems = readVectorItems(board.yItems);
2655
+ const preparedItems = prepareRealtimeItems(mergedItems);
2394
2656
  if (!preparedItems) return;
2395
- queuedItemsRef.current = null;
2657
+ queuedDirtyRef.current = false;
2396
2658
  outboundInFlightRef.current = {
2397
2659
  baseRevision,
2398
2660
  items: preparedItems.items,
@@ -2405,8 +2667,20 @@ function useRealtimeSession(options) {
2405
2667
  baseRevision,
2406
2668
  items: preparedItems.items
2407
2669
  });
2670
+ const pendingIds = getLocallyPendingItemIds(board);
2671
+ if (pendingIds.size > 0) {
2672
+ setLocalDraftRef.current({
2673
+ roomId,
2674
+ baseRevision,
2675
+ items: mergedItems,
2676
+ updatedAt: nowMs(),
2677
+ yDocState: encodeYDocState(board),
2678
+ pendingIds: Array.from(pendingIds)
2679
+ });
2680
+ scheduleDraftPersistenceRef.current?.();
2681
+ }
2408
2682
  if (!didSend) {
2409
- queuedItemsRef.current = next;
2683
+ queuedDirtyRef.current = true;
2410
2684
  outboundInFlightRef.current = null;
2411
2685
  setHasPendingDocumentSync(true);
2412
2686
  }
@@ -2421,66 +2695,103 @@ function useRealtimeSession(options) {
2421
2695
  }, DOCUMENT_FLUSH_DEBOUNCE_MS);
2422
2696
  }, DOCUMENT_FLUSH_DEBOUNCE_MS);
2423
2697
  }, [clearDocumentFlushSchedule, flushQueuedDocument]);
2424
- const queueDocumentSend = react.useCallback(
2425
- (items) => {
2426
- setLocalDraft({
2427
- roomId,
2428
- baseRevision: currentRevisionRef.current,
2429
- items,
2430
- updatedAt: nowMs()
2431
- });
2432
- scheduleDraftPersistence();
2433
- queuedItemsRef.current = items;
2434
- setHasPendingDocumentSync(true);
2435
- if (conflictRef.current) return;
2436
- scheduleDocumentFlushRef.current();
2437
- },
2438
- [roomId, scheduleDraftPersistence, setLocalDraft]
2439
- );
2698
+ const queueDocumentSend = react.useCallback((items) => {
2699
+ pendingLocalItemsRef.current = items;
2700
+ queuedDirtyRef.current = true;
2701
+ setHasPendingDocumentSync(true);
2702
+ setHasLocalOfflineDraft(true);
2703
+ scheduleDocumentFlushRef.current();
2704
+ }, []);
2440
2705
  const applyDraftSnapshot = react.useCallback(
2441
2706
  (draft, options2) => {
2442
- applyDocument(buildDraftSnapshot(draft, clientIdRef.current), options2);
2707
+ const board = boardRef.current;
2708
+ if (board) {
2709
+ let restoredFromBinary = false;
2710
+ if (draft.yDocState) {
2711
+ if (board.yItems.length > 0) {
2712
+ board.doc.transact(() => {
2713
+ board.yItems.delete(0, board.yItems.length);
2714
+ }, ORIGIN_BOOTSTRAP);
2715
+ }
2716
+ restoredFromBinary = decodeYDocState(board, draft.yDocState);
2717
+ if (restoredFromBinary) {
2718
+ const allIds = /* @__PURE__ */ new Set();
2719
+ for (let i = 0; i < board.yItems.length; i++) {
2720
+ const yMap = board.yItems.get(i);
2721
+ if (!yMap) continue;
2722
+ const id = yMap.get("id");
2723
+ if (typeof id === "string") allIds.add(id);
2724
+ }
2725
+ const pendingIds = new Set(draft.pendingIds ?? []);
2726
+ board.lastServerConfirmedIds = /* @__PURE__ */ new Set();
2727
+ for (const id of allIds) {
2728
+ if (!pendingIds.has(id)) {
2729
+ board.lastServerConfirmedIds.add(id);
2730
+ }
2731
+ }
2732
+ }
2733
+ }
2734
+ if (!restoredFromBinary) {
2735
+ replaceYDocWithSnapshot(board, {
2736
+ items: draft.items,
2737
+ origin: ORIGIN_BOOTSTRAP
2738
+ });
2739
+ board.lastServerConfirmedIds = /* @__PURE__ */ new Set();
2740
+ }
2741
+ }
2742
+ const items = board ? readVectorItems(board.yItems) : draft.items;
2743
+ const snapshot = {
2744
+ revision: draft.baseRevision,
2745
+ items,
2746
+ updatedAt: draft.updatedAt,
2747
+ updatedByClientId: clientIdRef.current
2748
+ };
2749
+ currentRevisionRef.current = snapshot.revision;
2750
+ latestDocumentRef.current = snapshot;
2751
+ setDocument(snapshot);
2752
+ if (!options2?.suppressSubscriberNotify) {
2753
+ notifySubscribers(items);
2754
+ }
2443
2755
  },
2444
- [applyDocument]
2756
+ [notifySubscribers]
2445
2757
  );
2446
2758
  const resolveAuthoritativeDocument = react.useCallback(
2447
2759
  (serverDocument, options2) => {
2448
- const localDraft = localDraftRef.current;
2449
- if (!localDraft) {
2450
- setConflictState(null);
2760
+ const board = boardRef.current;
2761
+ const hadLocalContent = localDraftRef.current != null || board != null && board.yItems.length > 0;
2762
+ applyDocument(serverDocument, {
2763
+ ...options2,
2764
+ replace: !hadLocalContent
2765
+ });
2766
+ if (!board) {
2451
2767
  setHasPendingDocumentSync(false);
2452
- applyDocument(serverDocument, options2);
2768
+ clearLocalDraftRef.current();
2453
2769
  return false;
2454
2770
  }
2455
- if (sameSerializedItems(localDraft.items, serverDocument.items)) {
2771
+ const pendingIds = getLocallyPendingItemIds(board);
2772
+ if (pendingIds.size === 0) {
2456
2773
  clearLocalDraftRef.current();
2457
2774
  setHasPendingDocumentSync(false);
2458
- setConflictState(null);
2459
- applyDocument(serverDocument, options2);
2460
- return true;
2461
- }
2462
- if (serverDocument.revision === localDraft.baseRevision) {
2463
- setConflictState(null);
2464
- applyDraftSnapshot(localDraft, {
2465
- suppressSubscriberNotify: options2?.suppressSubscriberNotify ?? sameSerializedItems(latestDocumentRef.current?.items, localDraft.items)
2466
- });
2467
2775
  outboundInFlightRef.current = null;
2468
- queuedItemsRef.current = localDraft.items;
2469
- setHasPendingDocumentSync(true);
2470
- scheduleDocumentFlush();
2471
- return true;
2776
+ queuedDirtyRef.current = false;
2777
+ return false;
2472
2778
  }
2473
- setConflictState({
2474
- serverRevision: serverDocument.revision,
2475
- serverItems: serverDocument.items,
2476
- localItems: localDraft.items
2477
- });
2478
- applyDraftSnapshot(localDraft, {
2479
- suppressSubscriberNotify: options2?.suppressSubscriberNotify ?? sameSerializedItems(latestDocumentRef.current?.items, localDraft.items)
2779
+ const mergedItems = readVectorItems(board.yItems);
2780
+ setLocalDraft({
2781
+ roomId,
2782
+ baseRevision: serverDocument.revision,
2783
+ items: mergedItems,
2784
+ updatedAt: nowMs(),
2785
+ yDocState: encodeYDocState(board),
2786
+ pendingIds: Array.from(pendingIds)
2480
2787
  });
2788
+ outboundInFlightRef.current = null;
2789
+ queuedDirtyRef.current = true;
2790
+ setHasPendingDocumentSync(true);
2791
+ scheduleDocumentFlush();
2481
2792
  return true;
2482
2793
  },
2483
- [applyDocument, applyDraftSnapshot, scheduleDocumentFlush, setConflictState]
2794
+ [applyDocument, roomId, scheduleDocumentFlush, setLocalDraft]
2484
2795
  );
2485
2796
  const sendPresenceUpdate = react.useCallback(() => {
2486
2797
  sendRaw({
@@ -2519,54 +2830,8 @@ function useRealtimeSession(options) {
2519
2830
  reconnect,
2520
2831
  updateConnection
2521
2832
  ]);
2522
- const resolveConflict = react.useCallback(
2523
- (action) => {
2524
- const activeConflict = conflictRef.current;
2525
- if (!activeConflict) return;
2526
- if (action === "use-server") {
2527
- clearLocalDraft();
2528
- queuedItemsRef.current = null;
2529
- outboundInFlightRef.current = null;
2530
- setConflictState(null);
2531
- applyDocument({
2532
- revision: activeConflict.serverRevision,
2533
- items: activeConflict.serverItems,
2534
- updatedAt: nowMs()
2535
- });
2536
- return;
2537
- }
2538
- const nextDraft = {
2539
- roomId,
2540
- baseRevision: activeConflict.serverRevision,
2541
- items: activeConflict.localItems,
2542
- updatedAt: nowMs()
2543
- };
2544
- setLocalDraft(nextDraft);
2545
- scheduleDraftPersistence();
2546
- setConflictState(null);
2547
- applyDraftSnapshot(nextDraft, {
2548
- suppressSubscriberNotify: sameSerializedItems(
2549
- latestDocumentRef.current?.items,
2550
- nextDraft.items
2551
- )
2552
- });
2553
- currentRevisionRef.current = activeConflict.serverRevision;
2554
- queuedItemsRef.current = nextDraft.items;
2555
- outboundInFlightRef.current = null;
2556
- setHasPendingDocumentSync(true);
2557
- scheduleDocumentFlush();
2558
- },
2559
- [
2560
- applyDocument,
2561
- applyDraftSnapshot,
2562
- clearLocalDraft,
2563
- roomId,
2564
- scheduleDocumentFlush,
2565
- scheduleDraftPersistence,
2566
- setConflictState,
2567
- setLocalDraft
2568
- ]
2569
- );
2833
+ const resolveConflict = react.useCallback((_action) => {
2834
+ }, []);
2570
2835
  const setConflictStateRef = react.useRef(setConflictState);
2571
2836
  setConflictStateRef.current = setConflictState;
2572
2837
  const updateConnectionRef = react.useRef(updateConnection);
@@ -2587,7 +2852,15 @@ function useRealtimeSession(options) {
2587
2852
  scheduleReconnectRef.current = scheduleReconnect;
2588
2853
  const sendRawRef = react.useRef(sendRaw);
2589
2854
  sendRawRef.current = sendRaw;
2855
+ const setLocalDraftRef = react.useRef(setLocalDraft);
2856
+ setLocalDraftRef.current = setLocalDraft;
2857
+ const scheduleDraftPersistenceRef = react.useRef(scheduleDraftPersistence);
2858
+ scheduleDraftPersistenceRef.current = scheduleDraftPersistence;
2590
2859
  react.useEffect(() => {
2860
+ if (boardRef.current) {
2861
+ boardRef.current.doc.destroy();
2862
+ }
2863
+ boardRef.current = createYjsBoardDoc();
2591
2864
  if (!roomId) {
2592
2865
  clearDocumentFlushSchedule();
2593
2866
  clearDraftPersistSchedule();
@@ -2595,7 +2868,8 @@ function useRealtimeSession(options) {
2595
2868
  setHasLocalOfflineDraft(false);
2596
2869
  setHasPendingDocumentSync(false);
2597
2870
  setConflictState(null);
2598
- queuedItemsRef.current = null;
2871
+ queuedDirtyRef.current = false;
2872
+ pendingLocalItemsRef.current = null;
2599
2873
  outboundInFlightRef.current = null;
2600
2874
  latestDocumentRef.current = null;
2601
2875
  setDocument(null);
@@ -2606,7 +2880,8 @@ function useRealtimeSession(options) {
2606
2880
  setLocalDraft(localDraft);
2607
2881
  setHasPendingDocumentSync(localDraft != null);
2608
2882
  setConflictState(null);
2609
- queuedItemsRef.current = localDraft?.items ?? null;
2883
+ queuedDirtyRef.current = localDraft != null;
2884
+ pendingLocalItemsRef.current = null;
2610
2885
  outboundInFlightRef.current = null;
2611
2886
  if (localDraft) {
2612
2887
  applyDraftSnapshot(localDraft, {
@@ -2637,7 +2912,7 @@ function useRealtimeSession(options) {
2637
2912
  clearDocumentFlushSchedule();
2638
2913
  wsRef.current?.close();
2639
2914
  wsRef.current = null;
2640
- queuedItemsRef.current = localDraftRef.current?.items ?? null;
2915
+ queuedDirtyRef.current = localDraftRef.current != null;
2641
2916
  outboundInFlightRef.current = null;
2642
2917
  setHasPendingDocumentSync(localDraftRef.current != null);
2643
2918
  collapsePeersToSelfRef.current("offline");
@@ -2752,7 +3027,7 @@ function useRealtimeSession(options) {
2752
3027
  }
2753
3028
  );
2754
3029
  if (!handledByDraft) {
2755
- queuedItemsRef.current = null;
3030
+ queuedDirtyRef.current = false;
2756
3031
  outboundInFlightRef.current = null;
2757
3032
  setHasPendingDocumentSync(false);
2758
3033
  }
@@ -2803,8 +3078,6 @@ function useRealtimeSession(options) {
2803
3078
  const isSelfAck = parsed.document.updatedByClientId === selfClientId;
2804
3079
  if (!isSelfAck) {
2805
3080
  outboundInFlightRef.current = null;
2806
- queuedItemsRef.current = localDraftRef.current?.items ?? null;
2807
- setHasPendingDocumentSync(queuedItemsRef.current != null);
2808
3081
  resolveAuthoritativeDocumentRef.current(
2809
3082
  sanitizeRealtimeSnapshot(parsed.document)
2810
3083
  );
@@ -2815,24 +3088,33 @@ function useRealtimeSession(options) {
2815
3088
  applyDocumentRef.current(sanitizeRealtimeSnapshot(parsed.document), {
2816
3089
  suppressSubscriberNotify: shouldSuppress
2817
3090
  });
2818
- if (queuedItemsRef.current) {
2819
- if (sameSerializedItems(queuedItemsRef.current, parsed.document.items)) {
2820
- queuedItemsRef.current = null;
2821
- } else {
2822
- scheduleDocumentFlushRef.current();
2823
- }
2824
- }
2825
- if (!queuedItemsRef.current) {
3091
+ const board = boardRef.current;
3092
+ const stillPending = board ? getLocallyPendingItemIds(board).size > 0 : false;
3093
+ if (stillPending) {
3094
+ const mergedItems = board ? readVectorItems(board.yItems) : [];
3095
+ setLocalDraftRef.current({
3096
+ roomId,
3097
+ baseRevision: currentRevisionRef.current,
3098
+ items: mergedItems,
3099
+ updatedAt: nowMs(),
3100
+ yDocState: board ? encodeYDocState(board) : void 0,
3101
+ pendingIds: board ? Array.from(getLocallyPendingItemIds(board)) : void 0
3102
+ });
3103
+ queuedDirtyRef.current = true;
3104
+ setHasPendingDocumentSync(true);
3105
+ scheduleDocumentFlushRef.current();
3106
+ } else {
3107
+ queuedDirtyRef.current = false;
2826
3108
  clearLocalDraftRef.current();
3109
+ setHasPendingDocumentSync(false);
2827
3110
  }
2828
- setHasPendingDocumentSync(queuedItemsRef.current != null);
2829
3111
  setConflictStateRef.current(null);
2830
3112
  return;
2831
3113
  }
2832
3114
  if (parsed.type === "document:resync-required") {
2833
3115
  outboundInFlightRef.current = null;
2834
- queuedItemsRef.current = localDraftRef.current?.items ?? null;
2835
- setHasPendingDocumentSync(queuedItemsRef.current != null);
3116
+ queuedDirtyRef.current = localDraftRef.current != null;
3117
+ setHasPendingDocumentSync(queuedDirtyRef.current);
2836
3118
  updateConnectionRef.current((prev) => ({
2837
3119
  ...prev,
2838
3120
  lastError: parsed.reason
@@ -2907,14 +3189,39 @@ function useRealtimeSession(options) {
2907
3189
  () => () => {
2908
3190
  clearDocumentFlushSchedule();
2909
3191
  clearDraftPersistSchedule();
3192
+ if (boardRef.current) {
3193
+ boardRef.current.doc.destroy();
3194
+ boardRef.current = null;
3195
+ }
2910
3196
  },
2911
3197
  [clearDocumentFlushSchedule, clearDraftPersistSchedule]
2912
3198
  );
2913
3199
  const flushDocumentSync = react.useCallback(async () => {
3200
+ const board = boardRef.current;
3201
+ const pendingLocal = pendingLocalItemsRef.current;
3202
+ if (board && pendingLocal) {
3203
+ pendingLocalItemsRef.current = null;
3204
+ applyLocalItemsToYDoc(board, {
3205
+ items: pendingLocal,
3206
+ origin: ORIGIN_LOCAL
3207
+ });
3208
+ const mergedItems = readVectorItems(board.yItems);
3209
+ const pendingIds = getLocallyPendingItemIds(board);
3210
+ if (pendingIds.size > 0) {
3211
+ setLocalDraftRef.current({
3212
+ roomId,
3213
+ baseRevision: currentRevisionRef.current,
3214
+ items: mergedItems,
3215
+ updatedAt: nowMs(),
3216
+ yDocState: encodeYDocState(board),
3217
+ pendingIds: Array.from(pendingIds)
3218
+ });
3219
+ }
3220
+ }
2914
3221
  persistLocalDraft();
2915
3222
  if (!connection.connected) return;
2916
3223
  flushQueuedDocument();
2917
- }, [connection.connected, flushQueuedDocument, persistLocalDraft]);
3224
+ }, [connection.connected, flushQueuedDocument, persistLocalDraft, roomId]);
2918
3225
  const remoteAdapter = react.useMemo(
2919
3226
  () => ({
2920
3227
  subscribe(onItems) {