canvu-react 0.3.40 → 0.4.0

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,241 @@ 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
+ const snapshotIds = /* @__PURE__ */ new Set();
2145
+ const snapshotById = /* @__PURE__ */ new Map();
2146
+ for (const item of snapshotItems) {
2147
+ const id = getItemId(item);
2148
+ if (!id) continue;
2149
+ snapshotIds.add(id);
2150
+ snapshotById.set(id, item);
2151
+ }
2152
+ const currentIndex = indexYItemsById(board.yItems);
2153
+ const toDeleteIds = [];
2154
+ for (const [id] of currentIndex) {
2155
+ if (snapshotIds.has(id)) continue;
2156
+ if (board.lastServerConfirmedIds.has(id)) {
2157
+ toDeleteIds.push(id);
2158
+ }
2159
+ }
2160
+ if (toDeleteIds.length > 0) {
2161
+ const refreshed = indexYItemsById(board.yItems);
2162
+ const sortedIndices = toDeleteIds.map((id) => ({ id, index: refreshed.get(id)?.index })).filter(
2163
+ (entry) => entry.index != null
2164
+ ).sort((a, b) => b.index - a.index);
2165
+ for (const { id, index } of sortedIndices) {
2166
+ board.yItems.delete(index, 1);
2167
+ board.serverItemSeenAt.delete(id);
2168
+ }
2169
+ }
2170
+ for (const item of snapshotItems) {
2171
+ const id = getItemId(item);
2172
+ if (!id) continue;
2173
+ const existing = indexYItemsById(board.yItems).get(id);
2174
+ if (existing) {
2175
+ updateYMapInPlace(existing.yMap, item);
2176
+ board.serverItemSeenAt.set(id, now);
2177
+ continue;
2178
+ }
2179
+ const yMap = vectorItemToYMap(item);
2180
+ board.yItems.push([yMap]);
2181
+ board.serverItemSeenAt.set(id, now);
2182
+ }
2183
+ }, origin);
2184
+ board.lastServerConfirmedIds = /* @__PURE__ */ new Set();
2185
+ for (const item of snapshotItems) {
2186
+ const id = getItemId(item);
2187
+ if (id) board.lastServerConfirmedIds.add(id);
2188
+ }
2189
+ }
2190
+ function replaceYDocWithSnapshot(board, options) {
2191
+ const { items: snapshotItems, origin } = options;
2192
+ const now = Date.now();
2193
+ board.doc.transact(() => {
2194
+ if (board.yItems.length > 0) {
2195
+ board.yItems.delete(0, board.yItems.length);
2196
+ }
2197
+ for (const item of snapshotItems) {
2198
+ board.yItems.push([vectorItemToYMap(item)]);
2199
+ }
2200
+ }, origin);
2201
+ board.lastServerConfirmedIds = /* @__PURE__ */ new Set();
2202
+ board.serverItemSeenAt = /* @__PURE__ */ new Map();
2203
+ for (const item of snapshotItems) {
2204
+ const id = getItemId(item);
2205
+ if (id) {
2206
+ board.lastServerConfirmedIds.add(id);
2207
+ board.serverItemSeenAt.set(id, now);
2208
+ }
2209
+ }
2210
+ }
2211
+ function getLocallyPendingItemIds(board) {
2212
+ const pending = /* @__PURE__ */ new Set();
2213
+ for (let i = 0; i < board.yItems.length; i++) {
2214
+ const yMap = board.yItems.get(i);
2215
+ if (!yMap) continue;
2216
+ const id = getItemId(yMap);
2217
+ if (!id) continue;
2218
+ if (!board.lastServerConfirmedIds.has(id)) {
2219
+ pending.add(id);
2220
+ }
2221
+ }
2222
+ return pending;
2223
+ }
2224
+ function encodeYDocState(board) {
2225
+ const update = Y__namespace.encodeStateAsUpdate(board.doc);
2226
+ let binary = "";
2227
+ for (let i = 0; i < update.length; i++) {
2228
+ binary += String.fromCharCode(update[i]);
2229
+ }
2230
+ return btoa(binary);
2231
+ }
2232
+ function decodeYDocState(board, encoded) {
2233
+ try {
2234
+ const binary = atob(encoded);
2235
+ const update = new Uint8Array(binary.length);
2236
+ for (let i = 0; i < binary.length; i++) {
2237
+ update[i] = binary.charCodeAt(i);
2238
+ }
2239
+ Y__namespace.applyUpdate(board.doc, update);
2240
+ return true;
2241
+ } catch {
2242
+ return false;
2243
+ }
2244
+ }
1995
2245
 
1996
2246
  // src/react/plugins/realtime/use-realtime-session.ts
2247
+ var ORIGIN_LOCAL = /* @__PURE__ */ Symbol("canvu/realtime/local");
2248
+ var ORIGIN_REMOTE = /* @__PURE__ */ Symbol("canvu/realtime/remote");
2249
+ var ORIGIN_BOOTSTRAP = /* @__PURE__ */ Symbol("canvu/realtime/bootstrap");
1997
2250
  var DRAFT_STORAGE_PREFIX = "canvu-realtime-draft:";
1998
2251
  var DOCUMENT_FLUSH_DEBOUNCE_MS = 120;
1999
2252
  var DRAFT_PERSIST_DEBOUNCE_MS = 420;
@@ -2080,7 +2333,16 @@ function draftStorageKey(roomId) {
2080
2333
  function isRealtimeOfflineDraft(value) {
2081
2334
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
2082
2335
  const record = value;
2083
- return typeof record.roomId === "string" && typeof record.baseRevision === "number" && typeof record.updatedAt === "number" && Array.isArray(record.items);
2336
+ if (typeof record.roomId !== "string" || typeof record.baseRevision !== "number" || typeof record.updatedAt !== "number" || !Array.isArray(record.items)) {
2337
+ return false;
2338
+ }
2339
+ if (record.yDocState != null && typeof record.yDocState !== "string") {
2340
+ return false;
2341
+ }
2342
+ if (record.pendingIds != null && !Array.isArray(record.pendingIds)) {
2343
+ return false;
2344
+ }
2345
+ return true;
2084
2346
  }
2085
2347
  function readRealtimeOfflineDraft(roomId) {
2086
2348
  if (typeof window === "undefined" || !roomId) return null;
@@ -2118,14 +2380,6 @@ function removeRealtimeOfflineDraft(roomId) {
2118
2380
  } catch {
2119
2381
  }
2120
2382
  }
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
2383
  function getViewportCameraSnapshot(viewport) {
2130
2384
  if (!viewport) return null;
2131
2385
  const camera = viewport.getCamera();
@@ -2176,8 +2430,13 @@ function useRealtimeSession(options) {
2176
2430
  const retryCountRef = react.useRef(0);
2177
2431
  const currentRevisionRef = react.useRef(0);
2178
2432
  const outboundInFlightRef = react.useRef(null);
2179
- const queuedItemsRef = react.useRef(null);
2433
+ const queuedDirtyRef = react.useRef(false);
2434
+ const pendingLocalItemsRef = react.useRef(null);
2180
2435
  const subscriberRefs = react.useRef(/* @__PURE__ */ new Set());
2436
+ const boardRef = react.useRef(null);
2437
+ if (boardRef.current == null) {
2438
+ boardRef.current = createYjsBoardDoc();
2439
+ }
2181
2440
  const lastCursorRef = react.useRef(null);
2182
2441
  const lastMarkupStrokeRef = react.useRef(null);
2183
2442
  const lastCameraRef = react.useRef(
@@ -2272,11 +2531,30 @@ function useRealtimeSession(options) {
2272
2531
  if (currentSnapshot && currentSnapshot.revision === snapshot.revision && currentSnapshot.updatedByClientId === snapshot.updatedByClientId && sameSerializedItems(currentSnapshot.items, snapshot.items)) {
2273
2532
  return;
2274
2533
  }
2534
+ const board = boardRef.current;
2535
+ if (board) {
2536
+ if (options2?.replace) {
2537
+ replaceYDocWithSnapshot(board, {
2538
+ items: snapshot.items,
2539
+ origin: ORIGIN_REMOTE
2540
+ });
2541
+ } else {
2542
+ applyServerSnapshotToYDoc(board, {
2543
+ items: snapshot.items,
2544
+ origin: ORIGIN_REMOTE
2545
+ });
2546
+ }
2547
+ }
2548
+ const mergedItems = board ? readVectorItems(board.yItems) : snapshot.items;
2549
+ const mergedSnapshot = {
2550
+ ...snapshot,
2551
+ items: mergedItems
2552
+ };
2275
2553
  currentRevisionRef.current = snapshot.revision;
2276
- latestDocumentRef.current = snapshot;
2277
- setDocument(snapshot);
2554
+ latestDocumentRef.current = mergedSnapshot;
2555
+ setDocument(mergedSnapshot);
2278
2556
  if (!options2?.suppressSubscriberNotify) {
2279
- notifySubscribers(snapshot.items);
2557
+ notifySubscribers(mergedItems);
2280
2558
  }
2281
2559
  },
2282
2560
  [notifySubscribers]
@@ -2386,13 +2664,23 @@ function useRealtimeSession(options) {
2386
2664
  );
2387
2665
  const flushQueuedDocument = react.useCallback(() => {
2388
2666
  clearDocumentFlushSchedule();
2389
- if (conflictRef.current) return;
2390
- const next = queuedItemsRef.current;
2391
- if (!next || outboundInFlightRef.current) return;
2667
+ const board = boardRef.current;
2668
+ if (!board) return;
2669
+ if (!queuedDirtyRef.current && pendingLocalItemsRef.current == null) return;
2670
+ if (outboundInFlightRef.current) return;
2671
+ const pendingLocal = pendingLocalItemsRef.current;
2672
+ if (pendingLocal) {
2673
+ pendingLocalItemsRef.current = null;
2674
+ applyLocalItemsToYDoc(board, {
2675
+ items: pendingLocal,
2676
+ origin: ORIGIN_LOCAL
2677
+ });
2678
+ }
2392
2679
  const baseRevision = currentRevisionRef.current;
2393
- const preparedItems = prepareRealtimeItems(next);
2680
+ const mergedItems = readVectorItems(board.yItems);
2681
+ const preparedItems = prepareRealtimeItems(mergedItems);
2394
2682
  if (!preparedItems) return;
2395
- queuedItemsRef.current = null;
2683
+ queuedDirtyRef.current = false;
2396
2684
  outboundInFlightRef.current = {
2397
2685
  baseRevision,
2398
2686
  items: preparedItems.items,
@@ -2405,8 +2693,20 @@ function useRealtimeSession(options) {
2405
2693
  baseRevision,
2406
2694
  items: preparedItems.items
2407
2695
  });
2696
+ const pendingIds = getLocallyPendingItemIds(board);
2697
+ if (pendingIds.size > 0) {
2698
+ setLocalDraftRef.current({
2699
+ roomId,
2700
+ baseRevision,
2701
+ items: mergedItems,
2702
+ updatedAt: nowMs(),
2703
+ yDocState: encodeYDocState(board),
2704
+ pendingIds: Array.from(pendingIds)
2705
+ });
2706
+ scheduleDraftPersistenceRef.current?.();
2707
+ }
2408
2708
  if (!didSend) {
2409
- queuedItemsRef.current = next;
2709
+ queuedDirtyRef.current = true;
2410
2710
  outboundInFlightRef.current = null;
2411
2711
  setHasPendingDocumentSync(true);
2412
2712
  }
@@ -2421,66 +2721,103 @@ function useRealtimeSession(options) {
2421
2721
  }, DOCUMENT_FLUSH_DEBOUNCE_MS);
2422
2722
  }, DOCUMENT_FLUSH_DEBOUNCE_MS);
2423
2723
  }, [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
- );
2724
+ const queueDocumentSend = react.useCallback((items) => {
2725
+ pendingLocalItemsRef.current = items;
2726
+ queuedDirtyRef.current = true;
2727
+ setHasPendingDocumentSync(true);
2728
+ setHasLocalOfflineDraft(true);
2729
+ scheduleDocumentFlushRef.current();
2730
+ }, []);
2440
2731
  const applyDraftSnapshot = react.useCallback(
2441
2732
  (draft, options2) => {
2442
- applyDocument(buildDraftSnapshot(draft, clientIdRef.current), options2);
2733
+ const board = boardRef.current;
2734
+ if (board) {
2735
+ let restoredFromBinary = false;
2736
+ if (draft.yDocState) {
2737
+ if (board.yItems.length > 0) {
2738
+ board.doc.transact(() => {
2739
+ board.yItems.delete(0, board.yItems.length);
2740
+ }, ORIGIN_BOOTSTRAP);
2741
+ }
2742
+ restoredFromBinary = decodeYDocState(board, draft.yDocState);
2743
+ if (restoredFromBinary) {
2744
+ const allIds = /* @__PURE__ */ new Set();
2745
+ for (let i = 0; i < board.yItems.length; i++) {
2746
+ const yMap = board.yItems.get(i);
2747
+ if (!yMap) continue;
2748
+ const id = yMap.get("id");
2749
+ if (typeof id === "string") allIds.add(id);
2750
+ }
2751
+ const pendingIds = new Set(draft.pendingIds ?? []);
2752
+ board.lastServerConfirmedIds = /* @__PURE__ */ new Set();
2753
+ for (const id of allIds) {
2754
+ if (!pendingIds.has(id)) {
2755
+ board.lastServerConfirmedIds.add(id);
2756
+ }
2757
+ }
2758
+ }
2759
+ }
2760
+ if (!restoredFromBinary) {
2761
+ replaceYDocWithSnapshot(board, {
2762
+ items: draft.items,
2763
+ origin: ORIGIN_BOOTSTRAP
2764
+ });
2765
+ board.lastServerConfirmedIds = /* @__PURE__ */ new Set();
2766
+ }
2767
+ }
2768
+ const items = board ? readVectorItems(board.yItems) : draft.items;
2769
+ const snapshot = {
2770
+ revision: draft.baseRevision,
2771
+ items,
2772
+ updatedAt: draft.updatedAt,
2773
+ updatedByClientId: clientIdRef.current
2774
+ };
2775
+ currentRevisionRef.current = snapshot.revision;
2776
+ latestDocumentRef.current = snapshot;
2777
+ setDocument(snapshot);
2778
+ if (!options2?.suppressSubscriberNotify) {
2779
+ notifySubscribers(items);
2780
+ }
2443
2781
  },
2444
- [applyDocument]
2782
+ [notifySubscribers]
2445
2783
  );
2446
2784
  const resolveAuthoritativeDocument = react.useCallback(
2447
2785
  (serverDocument, options2) => {
2448
- const localDraft = localDraftRef.current;
2449
- if (!localDraft) {
2450
- setConflictState(null);
2786
+ const board = boardRef.current;
2787
+ const hadLocalContent = localDraftRef.current != null || board != null && board.yItems.length > 0;
2788
+ applyDocument(serverDocument, {
2789
+ ...options2,
2790
+ replace: !hadLocalContent
2791
+ });
2792
+ if (!board) {
2451
2793
  setHasPendingDocumentSync(false);
2452
- applyDocument(serverDocument, options2);
2794
+ clearLocalDraftRef.current();
2453
2795
  return false;
2454
2796
  }
2455
- if (sameSerializedItems(localDraft.items, serverDocument.items)) {
2797
+ const pendingIds = getLocallyPendingItemIds(board);
2798
+ if (pendingIds.size === 0) {
2456
2799
  clearLocalDraftRef.current();
2457
2800
  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
2801
  outboundInFlightRef.current = null;
2468
- queuedItemsRef.current = localDraft.items;
2469
- setHasPendingDocumentSync(true);
2470
- scheduleDocumentFlush();
2471
- return true;
2802
+ queuedDirtyRef.current = false;
2803
+ return false;
2472
2804
  }
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)
2805
+ const mergedItems = readVectorItems(board.yItems);
2806
+ setLocalDraft({
2807
+ roomId,
2808
+ baseRevision: serverDocument.revision,
2809
+ items: mergedItems,
2810
+ updatedAt: nowMs(),
2811
+ yDocState: encodeYDocState(board),
2812
+ pendingIds: Array.from(pendingIds)
2480
2813
  });
2814
+ outboundInFlightRef.current = null;
2815
+ queuedDirtyRef.current = true;
2816
+ setHasPendingDocumentSync(true);
2817
+ scheduleDocumentFlush();
2481
2818
  return true;
2482
2819
  },
2483
- [applyDocument, applyDraftSnapshot, scheduleDocumentFlush, setConflictState]
2820
+ [applyDocument, roomId, scheduleDocumentFlush, setLocalDraft]
2484
2821
  );
2485
2822
  const sendPresenceUpdate = react.useCallback(() => {
2486
2823
  sendRaw({
@@ -2519,54 +2856,8 @@ function useRealtimeSession(options) {
2519
2856
  reconnect,
2520
2857
  updateConnection
2521
2858
  ]);
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
- );
2859
+ const resolveConflict = react.useCallback((_action) => {
2860
+ }, []);
2570
2861
  const setConflictStateRef = react.useRef(setConflictState);
2571
2862
  setConflictStateRef.current = setConflictState;
2572
2863
  const updateConnectionRef = react.useRef(updateConnection);
@@ -2587,7 +2878,15 @@ function useRealtimeSession(options) {
2587
2878
  scheduleReconnectRef.current = scheduleReconnect;
2588
2879
  const sendRawRef = react.useRef(sendRaw);
2589
2880
  sendRawRef.current = sendRaw;
2881
+ const setLocalDraftRef = react.useRef(setLocalDraft);
2882
+ setLocalDraftRef.current = setLocalDraft;
2883
+ const scheduleDraftPersistenceRef = react.useRef(scheduleDraftPersistence);
2884
+ scheduleDraftPersistenceRef.current = scheduleDraftPersistence;
2590
2885
  react.useEffect(() => {
2886
+ if (boardRef.current) {
2887
+ boardRef.current.doc.destroy();
2888
+ }
2889
+ boardRef.current = createYjsBoardDoc();
2591
2890
  if (!roomId) {
2592
2891
  clearDocumentFlushSchedule();
2593
2892
  clearDraftPersistSchedule();
@@ -2595,7 +2894,8 @@ function useRealtimeSession(options) {
2595
2894
  setHasLocalOfflineDraft(false);
2596
2895
  setHasPendingDocumentSync(false);
2597
2896
  setConflictState(null);
2598
- queuedItemsRef.current = null;
2897
+ queuedDirtyRef.current = false;
2898
+ pendingLocalItemsRef.current = null;
2599
2899
  outboundInFlightRef.current = null;
2600
2900
  latestDocumentRef.current = null;
2601
2901
  setDocument(null);
@@ -2606,7 +2906,8 @@ function useRealtimeSession(options) {
2606
2906
  setLocalDraft(localDraft);
2607
2907
  setHasPendingDocumentSync(localDraft != null);
2608
2908
  setConflictState(null);
2609
- queuedItemsRef.current = localDraft?.items ?? null;
2909
+ queuedDirtyRef.current = localDraft != null;
2910
+ pendingLocalItemsRef.current = null;
2610
2911
  outboundInFlightRef.current = null;
2611
2912
  if (localDraft) {
2612
2913
  applyDraftSnapshot(localDraft, {
@@ -2637,7 +2938,7 @@ function useRealtimeSession(options) {
2637
2938
  clearDocumentFlushSchedule();
2638
2939
  wsRef.current?.close();
2639
2940
  wsRef.current = null;
2640
- queuedItemsRef.current = localDraftRef.current?.items ?? null;
2941
+ queuedDirtyRef.current = localDraftRef.current != null;
2641
2942
  outboundInFlightRef.current = null;
2642
2943
  setHasPendingDocumentSync(localDraftRef.current != null);
2643
2944
  collapsePeersToSelfRef.current("offline");
@@ -2752,7 +3053,7 @@ function useRealtimeSession(options) {
2752
3053
  }
2753
3054
  );
2754
3055
  if (!handledByDraft) {
2755
- queuedItemsRef.current = null;
3056
+ queuedDirtyRef.current = false;
2756
3057
  outboundInFlightRef.current = null;
2757
3058
  setHasPendingDocumentSync(false);
2758
3059
  }
@@ -2803,8 +3104,6 @@ function useRealtimeSession(options) {
2803
3104
  const isSelfAck = parsed.document.updatedByClientId === selfClientId;
2804
3105
  if (!isSelfAck) {
2805
3106
  outboundInFlightRef.current = null;
2806
- queuedItemsRef.current = localDraftRef.current?.items ?? null;
2807
- setHasPendingDocumentSync(queuedItemsRef.current != null);
2808
3107
  resolveAuthoritativeDocumentRef.current(
2809
3108
  sanitizeRealtimeSnapshot(parsed.document)
2810
3109
  );
@@ -2815,24 +3114,33 @@ function useRealtimeSession(options) {
2815
3114
  applyDocumentRef.current(sanitizeRealtimeSnapshot(parsed.document), {
2816
3115
  suppressSubscriberNotify: shouldSuppress
2817
3116
  });
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) {
3117
+ const board = boardRef.current;
3118
+ const stillPending = board ? getLocallyPendingItemIds(board).size > 0 : false;
3119
+ if (stillPending) {
3120
+ const mergedItems = board ? readVectorItems(board.yItems) : [];
3121
+ setLocalDraftRef.current({
3122
+ roomId,
3123
+ baseRevision: currentRevisionRef.current,
3124
+ items: mergedItems,
3125
+ updatedAt: nowMs(),
3126
+ yDocState: board ? encodeYDocState(board) : void 0,
3127
+ pendingIds: board ? Array.from(getLocallyPendingItemIds(board)) : void 0
3128
+ });
3129
+ queuedDirtyRef.current = true;
3130
+ setHasPendingDocumentSync(true);
3131
+ scheduleDocumentFlushRef.current();
3132
+ } else {
3133
+ queuedDirtyRef.current = false;
2826
3134
  clearLocalDraftRef.current();
3135
+ setHasPendingDocumentSync(false);
2827
3136
  }
2828
- setHasPendingDocumentSync(queuedItemsRef.current != null);
2829
3137
  setConflictStateRef.current(null);
2830
3138
  return;
2831
3139
  }
2832
3140
  if (parsed.type === "document:resync-required") {
2833
3141
  outboundInFlightRef.current = null;
2834
- queuedItemsRef.current = localDraftRef.current?.items ?? null;
2835
- setHasPendingDocumentSync(queuedItemsRef.current != null);
3142
+ queuedDirtyRef.current = localDraftRef.current != null;
3143
+ setHasPendingDocumentSync(queuedDirtyRef.current);
2836
3144
  updateConnectionRef.current((prev) => ({
2837
3145
  ...prev,
2838
3146
  lastError: parsed.reason
@@ -2907,14 +3215,39 @@ function useRealtimeSession(options) {
2907
3215
  () => () => {
2908
3216
  clearDocumentFlushSchedule();
2909
3217
  clearDraftPersistSchedule();
3218
+ if (boardRef.current) {
3219
+ boardRef.current.doc.destroy();
3220
+ boardRef.current = null;
3221
+ }
2910
3222
  },
2911
3223
  [clearDocumentFlushSchedule, clearDraftPersistSchedule]
2912
3224
  );
2913
3225
  const flushDocumentSync = react.useCallback(async () => {
3226
+ const board = boardRef.current;
3227
+ const pendingLocal = pendingLocalItemsRef.current;
3228
+ if (board && pendingLocal) {
3229
+ pendingLocalItemsRef.current = null;
3230
+ applyLocalItemsToYDoc(board, {
3231
+ items: pendingLocal,
3232
+ origin: ORIGIN_LOCAL
3233
+ });
3234
+ const mergedItems = readVectorItems(board.yItems);
3235
+ const pendingIds = getLocallyPendingItemIds(board);
3236
+ if (pendingIds.size > 0) {
3237
+ setLocalDraftRef.current({
3238
+ roomId,
3239
+ baseRevision: currentRevisionRef.current,
3240
+ items: mergedItems,
3241
+ updatedAt: nowMs(),
3242
+ yDocState: encodeYDocState(board),
3243
+ pendingIds: Array.from(pendingIds)
3244
+ });
3245
+ }
3246
+ }
2914
3247
  persistLocalDraft();
2915
3248
  if (!connection.connected) return;
2916
3249
  flushQueuedDocument();
2917
- }, [connection.connected, flushQueuedDocument, persistLocalDraft]);
3250
+ }, [connection.connected, flushQueuedDocument, persistLocalDraft, roomId]);
2918
3251
  const remoteAdapter = react.useMemo(
2919
3252
  () => ({
2920
3253
  subscribe(onItems) {