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