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.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,215 @@ 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
+ for (const item of snapshotItems) {
2120
+ const id = getItemId(item);
2121
+ if (!id) continue;
2122
+ const existing = indexYItemsById(board.yItems).get(id);
2123
+ if (existing) {
2124
+ updateYMapInPlace(existing.yMap, item);
2125
+ board.serverItemSeenAt.set(id, now);
2126
+ continue;
2127
+ }
2128
+ const yMap = vectorItemToYMap(item);
2129
+ board.yItems.push([yMap]);
2130
+ board.serverItemSeenAt.set(id, now);
2131
+ }
2132
+ }, origin);
2133
+ board.lastServerConfirmedIds = /* @__PURE__ */ new Set();
2134
+ for (const item of snapshotItems) {
2135
+ const id = getItemId(item);
2136
+ if (id) board.lastServerConfirmedIds.add(id);
2137
+ }
2138
+ }
2139
+ function replaceYDocWithSnapshot(board, options) {
2140
+ const { items: snapshotItems, origin } = options;
2141
+ const now = Date.now();
2142
+ board.doc.transact(() => {
2143
+ if (board.yItems.length > 0) {
2144
+ board.yItems.delete(0, board.yItems.length);
2145
+ }
2146
+ for (const item of snapshotItems) {
2147
+ board.yItems.push([vectorItemToYMap(item)]);
2148
+ }
2149
+ }, origin);
2150
+ board.lastServerConfirmedIds = /* @__PURE__ */ new Set();
2151
+ board.serverItemSeenAt = /* @__PURE__ */ new Map();
2152
+ for (const item of snapshotItems) {
2153
+ const id = getItemId(item);
2154
+ if (id) {
2155
+ board.lastServerConfirmedIds.add(id);
2156
+ board.serverItemSeenAt.set(id, now);
2157
+ }
2158
+ }
2159
+ }
2160
+ function getLocallyPendingItemIds(board) {
2161
+ const pending = /* @__PURE__ */ new Set();
2162
+ for (let i = 0; i < board.yItems.length; i++) {
2163
+ const yMap = board.yItems.get(i);
2164
+ if (!yMap) continue;
2165
+ const id = getItemId(yMap);
2166
+ if (!id) continue;
2167
+ if (!board.lastServerConfirmedIds.has(id)) {
2168
+ pending.add(id);
2169
+ }
2170
+ }
2171
+ return pending;
2172
+ }
2173
+ function encodeYDocState(board) {
2174
+ const update = Y.encodeStateAsUpdate(board.doc);
2175
+ let binary = "";
2176
+ for (let i = 0; i < update.length; i++) {
2177
+ binary += String.fromCharCode(update[i]);
2178
+ }
2179
+ return btoa(binary);
2180
+ }
2181
+ function decodeYDocState(board, encoded) {
2182
+ try {
2183
+ const binary = atob(encoded);
2184
+ const update = new Uint8Array(binary.length);
2185
+ for (let i = 0; i < binary.length; i++) {
2186
+ update[i] = binary.charCodeAt(i);
2187
+ }
2188
+ Y.applyUpdate(board.doc, update);
2189
+ return true;
2190
+ } catch {
2191
+ return false;
2192
+ }
2193
+ }
1989
2194
 
1990
2195
  // src/react/plugins/realtime/use-realtime-session.ts
2196
+ var ORIGIN_LOCAL = /* @__PURE__ */ Symbol("canvu/realtime/local");
2197
+ var ORIGIN_REMOTE = /* @__PURE__ */ Symbol("canvu/realtime/remote");
2198
+ var ORIGIN_BOOTSTRAP = /* @__PURE__ */ Symbol("canvu/realtime/bootstrap");
1991
2199
  var DRAFT_STORAGE_PREFIX = "canvu-realtime-draft:";
1992
2200
  var DOCUMENT_FLUSH_DEBOUNCE_MS = 120;
1993
2201
  var DRAFT_PERSIST_DEBOUNCE_MS = 420;
@@ -2074,7 +2282,16 @@ function draftStorageKey(roomId) {
2074
2282
  function isRealtimeOfflineDraft(value) {
2075
2283
  if (!value || typeof value !== "object" || Array.isArray(value)) return false;
2076
2284
  const record = value;
2077
- return typeof record.roomId === "string" && typeof record.baseRevision === "number" && typeof record.updatedAt === "number" && Array.isArray(record.items);
2285
+ if (typeof record.roomId !== "string" || typeof record.baseRevision !== "number" || typeof record.updatedAt !== "number" || !Array.isArray(record.items)) {
2286
+ return false;
2287
+ }
2288
+ if (record.yDocState != null && typeof record.yDocState !== "string") {
2289
+ return false;
2290
+ }
2291
+ if (record.pendingIds != null && !Array.isArray(record.pendingIds)) {
2292
+ return false;
2293
+ }
2294
+ return true;
2078
2295
  }
2079
2296
  function readRealtimeOfflineDraft(roomId) {
2080
2297
  if (typeof window === "undefined" || !roomId) return null;
@@ -2112,14 +2329,6 @@ function removeRealtimeOfflineDraft(roomId) {
2112
2329
  } catch {
2113
2330
  }
2114
2331
  }
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
2332
  function getViewportCameraSnapshot(viewport) {
2124
2333
  if (!viewport) return null;
2125
2334
  const camera = viewport.getCamera();
@@ -2170,8 +2379,13 @@ function useRealtimeSession(options) {
2170
2379
  const retryCountRef = useRef(0);
2171
2380
  const currentRevisionRef = useRef(0);
2172
2381
  const outboundInFlightRef = useRef(null);
2173
- const queuedItemsRef = useRef(null);
2382
+ const queuedDirtyRef = useRef(false);
2383
+ const pendingLocalItemsRef = useRef(null);
2174
2384
  const subscriberRefs = useRef(/* @__PURE__ */ new Set());
2385
+ const boardRef = useRef(null);
2386
+ if (boardRef.current == null) {
2387
+ boardRef.current = createYjsBoardDoc();
2388
+ }
2175
2389
  const lastCursorRef = useRef(null);
2176
2390
  const lastMarkupStrokeRef = useRef(null);
2177
2391
  const lastCameraRef = useRef(
@@ -2266,11 +2480,30 @@ function useRealtimeSession(options) {
2266
2480
  if (currentSnapshot && currentSnapshot.revision === snapshot.revision && currentSnapshot.updatedByClientId === snapshot.updatedByClientId && sameSerializedItems(currentSnapshot.items, snapshot.items)) {
2267
2481
  return;
2268
2482
  }
2483
+ const board = boardRef.current;
2484
+ if (board) {
2485
+ if (options2?.replace) {
2486
+ replaceYDocWithSnapshot(board, {
2487
+ items: snapshot.items,
2488
+ origin: ORIGIN_REMOTE
2489
+ });
2490
+ } else {
2491
+ applyServerSnapshotToYDoc(board, {
2492
+ items: snapshot.items,
2493
+ origin: ORIGIN_REMOTE
2494
+ });
2495
+ }
2496
+ }
2497
+ const mergedItems = board ? readVectorItems(board.yItems) : snapshot.items;
2498
+ const mergedSnapshot = {
2499
+ ...snapshot,
2500
+ items: mergedItems
2501
+ };
2269
2502
  currentRevisionRef.current = snapshot.revision;
2270
- latestDocumentRef.current = snapshot;
2271
- setDocument(snapshot);
2503
+ latestDocumentRef.current = mergedSnapshot;
2504
+ setDocument(mergedSnapshot);
2272
2505
  if (!options2?.suppressSubscriberNotify) {
2273
- notifySubscribers(snapshot.items);
2506
+ notifySubscribers(mergedItems);
2274
2507
  }
2275
2508
  },
2276
2509
  [notifySubscribers]
@@ -2380,13 +2613,23 @@ function useRealtimeSession(options) {
2380
2613
  );
2381
2614
  const flushQueuedDocument = useCallback(() => {
2382
2615
  clearDocumentFlushSchedule();
2383
- if (conflictRef.current) return;
2384
- const next = queuedItemsRef.current;
2385
- if (!next || outboundInFlightRef.current) return;
2616
+ const board = boardRef.current;
2617
+ if (!board) return;
2618
+ if (!queuedDirtyRef.current && pendingLocalItemsRef.current == null) return;
2619
+ if (outboundInFlightRef.current) return;
2620
+ const pendingLocal = pendingLocalItemsRef.current;
2621
+ if (pendingLocal) {
2622
+ pendingLocalItemsRef.current = null;
2623
+ applyLocalItemsToYDoc(board, {
2624
+ items: pendingLocal,
2625
+ origin: ORIGIN_LOCAL
2626
+ });
2627
+ }
2386
2628
  const baseRevision = currentRevisionRef.current;
2387
- const preparedItems = prepareRealtimeItems(next);
2629
+ const mergedItems = readVectorItems(board.yItems);
2630
+ const preparedItems = prepareRealtimeItems(mergedItems);
2388
2631
  if (!preparedItems) return;
2389
- queuedItemsRef.current = null;
2632
+ queuedDirtyRef.current = false;
2390
2633
  outboundInFlightRef.current = {
2391
2634
  baseRevision,
2392
2635
  items: preparedItems.items,
@@ -2399,8 +2642,20 @@ function useRealtimeSession(options) {
2399
2642
  baseRevision,
2400
2643
  items: preparedItems.items
2401
2644
  });
2645
+ const pendingIds = getLocallyPendingItemIds(board);
2646
+ if (pendingIds.size > 0) {
2647
+ setLocalDraftRef.current({
2648
+ roomId,
2649
+ baseRevision,
2650
+ items: mergedItems,
2651
+ updatedAt: nowMs(),
2652
+ yDocState: encodeYDocState(board),
2653
+ pendingIds: Array.from(pendingIds)
2654
+ });
2655
+ scheduleDraftPersistenceRef.current?.();
2656
+ }
2402
2657
  if (!didSend) {
2403
- queuedItemsRef.current = next;
2658
+ queuedDirtyRef.current = true;
2404
2659
  outboundInFlightRef.current = null;
2405
2660
  setHasPendingDocumentSync(true);
2406
2661
  }
@@ -2415,66 +2670,103 @@ function useRealtimeSession(options) {
2415
2670
  }, DOCUMENT_FLUSH_DEBOUNCE_MS);
2416
2671
  }, DOCUMENT_FLUSH_DEBOUNCE_MS);
2417
2672
  }, [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
- );
2673
+ const queueDocumentSend = useCallback((items) => {
2674
+ pendingLocalItemsRef.current = items;
2675
+ queuedDirtyRef.current = true;
2676
+ setHasPendingDocumentSync(true);
2677
+ setHasLocalOfflineDraft(true);
2678
+ scheduleDocumentFlushRef.current();
2679
+ }, []);
2434
2680
  const applyDraftSnapshot = useCallback(
2435
2681
  (draft, options2) => {
2436
- applyDocument(buildDraftSnapshot(draft, clientIdRef.current), options2);
2682
+ const board = boardRef.current;
2683
+ if (board) {
2684
+ let restoredFromBinary = false;
2685
+ if (draft.yDocState) {
2686
+ if (board.yItems.length > 0) {
2687
+ board.doc.transact(() => {
2688
+ board.yItems.delete(0, board.yItems.length);
2689
+ }, ORIGIN_BOOTSTRAP);
2690
+ }
2691
+ restoredFromBinary = decodeYDocState(board, draft.yDocState);
2692
+ if (restoredFromBinary) {
2693
+ const allIds = /* @__PURE__ */ new Set();
2694
+ for (let i = 0; i < board.yItems.length; i++) {
2695
+ const yMap = board.yItems.get(i);
2696
+ if (!yMap) continue;
2697
+ const id = yMap.get("id");
2698
+ if (typeof id === "string") allIds.add(id);
2699
+ }
2700
+ const pendingIds = new Set(draft.pendingIds ?? []);
2701
+ board.lastServerConfirmedIds = /* @__PURE__ */ new Set();
2702
+ for (const id of allIds) {
2703
+ if (!pendingIds.has(id)) {
2704
+ board.lastServerConfirmedIds.add(id);
2705
+ }
2706
+ }
2707
+ }
2708
+ }
2709
+ if (!restoredFromBinary) {
2710
+ replaceYDocWithSnapshot(board, {
2711
+ items: draft.items,
2712
+ origin: ORIGIN_BOOTSTRAP
2713
+ });
2714
+ board.lastServerConfirmedIds = /* @__PURE__ */ new Set();
2715
+ }
2716
+ }
2717
+ const items = board ? readVectorItems(board.yItems) : draft.items;
2718
+ const snapshot = {
2719
+ revision: draft.baseRevision,
2720
+ items,
2721
+ updatedAt: draft.updatedAt,
2722
+ updatedByClientId: clientIdRef.current
2723
+ };
2724
+ currentRevisionRef.current = snapshot.revision;
2725
+ latestDocumentRef.current = snapshot;
2726
+ setDocument(snapshot);
2727
+ if (!options2?.suppressSubscriberNotify) {
2728
+ notifySubscribers(items);
2729
+ }
2437
2730
  },
2438
- [applyDocument]
2731
+ [notifySubscribers]
2439
2732
  );
2440
2733
  const resolveAuthoritativeDocument = useCallback(
2441
2734
  (serverDocument, options2) => {
2442
- const localDraft = localDraftRef.current;
2443
- if (!localDraft) {
2444
- setConflictState(null);
2735
+ const board = boardRef.current;
2736
+ const hadLocalContent = localDraftRef.current != null || board != null && board.yItems.length > 0;
2737
+ applyDocument(serverDocument, {
2738
+ ...options2,
2739
+ replace: !hadLocalContent
2740
+ });
2741
+ if (!board) {
2445
2742
  setHasPendingDocumentSync(false);
2446
- applyDocument(serverDocument, options2);
2743
+ clearLocalDraftRef.current();
2447
2744
  return false;
2448
2745
  }
2449
- if (sameSerializedItems(localDraft.items, serverDocument.items)) {
2746
+ const pendingIds = getLocallyPendingItemIds(board);
2747
+ if (pendingIds.size === 0) {
2450
2748
  clearLocalDraftRef.current();
2451
2749
  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
2750
  outboundInFlightRef.current = null;
2462
- queuedItemsRef.current = localDraft.items;
2463
- setHasPendingDocumentSync(true);
2464
- scheduleDocumentFlush();
2465
- return true;
2751
+ queuedDirtyRef.current = false;
2752
+ return false;
2466
2753
  }
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)
2754
+ const mergedItems = readVectorItems(board.yItems);
2755
+ setLocalDraft({
2756
+ roomId,
2757
+ baseRevision: serverDocument.revision,
2758
+ items: mergedItems,
2759
+ updatedAt: nowMs(),
2760
+ yDocState: encodeYDocState(board),
2761
+ pendingIds: Array.from(pendingIds)
2474
2762
  });
2763
+ outboundInFlightRef.current = null;
2764
+ queuedDirtyRef.current = true;
2765
+ setHasPendingDocumentSync(true);
2766
+ scheduleDocumentFlush();
2475
2767
  return true;
2476
2768
  },
2477
- [applyDocument, applyDraftSnapshot, scheduleDocumentFlush, setConflictState]
2769
+ [applyDocument, roomId, scheduleDocumentFlush, setLocalDraft]
2478
2770
  );
2479
2771
  const sendPresenceUpdate = useCallback(() => {
2480
2772
  sendRaw({
@@ -2513,54 +2805,8 @@ function useRealtimeSession(options) {
2513
2805
  reconnect,
2514
2806
  updateConnection
2515
2807
  ]);
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
- );
2808
+ const resolveConflict = useCallback((_action) => {
2809
+ }, []);
2564
2810
  const setConflictStateRef = useRef(setConflictState);
2565
2811
  setConflictStateRef.current = setConflictState;
2566
2812
  const updateConnectionRef = useRef(updateConnection);
@@ -2581,7 +2827,15 @@ function useRealtimeSession(options) {
2581
2827
  scheduleReconnectRef.current = scheduleReconnect;
2582
2828
  const sendRawRef = useRef(sendRaw);
2583
2829
  sendRawRef.current = sendRaw;
2830
+ const setLocalDraftRef = useRef(setLocalDraft);
2831
+ setLocalDraftRef.current = setLocalDraft;
2832
+ const scheduleDraftPersistenceRef = useRef(scheduleDraftPersistence);
2833
+ scheduleDraftPersistenceRef.current = scheduleDraftPersistence;
2584
2834
  useEffect(() => {
2835
+ if (boardRef.current) {
2836
+ boardRef.current.doc.destroy();
2837
+ }
2838
+ boardRef.current = createYjsBoardDoc();
2585
2839
  if (!roomId) {
2586
2840
  clearDocumentFlushSchedule();
2587
2841
  clearDraftPersistSchedule();
@@ -2589,7 +2843,8 @@ function useRealtimeSession(options) {
2589
2843
  setHasLocalOfflineDraft(false);
2590
2844
  setHasPendingDocumentSync(false);
2591
2845
  setConflictState(null);
2592
- queuedItemsRef.current = null;
2846
+ queuedDirtyRef.current = false;
2847
+ pendingLocalItemsRef.current = null;
2593
2848
  outboundInFlightRef.current = null;
2594
2849
  latestDocumentRef.current = null;
2595
2850
  setDocument(null);
@@ -2600,7 +2855,8 @@ function useRealtimeSession(options) {
2600
2855
  setLocalDraft(localDraft);
2601
2856
  setHasPendingDocumentSync(localDraft != null);
2602
2857
  setConflictState(null);
2603
- queuedItemsRef.current = localDraft?.items ?? null;
2858
+ queuedDirtyRef.current = localDraft != null;
2859
+ pendingLocalItemsRef.current = null;
2604
2860
  outboundInFlightRef.current = null;
2605
2861
  if (localDraft) {
2606
2862
  applyDraftSnapshot(localDraft, {
@@ -2631,7 +2887,7 @@ function useRealtimeSession(options) {
2631
2887
  clearDocumentFlushSchedule();
2632
2888
  wsRef.current?.close();
2633
2889
  wsRef.current = null;
2634
- queuedItemsRef.current = localDraftRef.current?.items ?? null;
2890
+ queuedDirtyRef.current = localDraftRef.current != null;
2635
2891
  outboundInFlightRef.current = null;
2636
2892
  setHasPendingDocumentSync(localDraftRef.current != null);
2637
2893
  collapsePeersToSelfRef.current("offline");
@@ -2746,7 +3002,7 @@ function useRealtimeSession(options) {
2746
3002
  }
2747
3003
  );
2748
3004
  if (!handledByDraft) {
2749
- queuedItemsRef.current = null;
3005
+ queuedDirtyRef.current = false;
2750
3006
  outboundInFlightRef.current = null;
2751
3007
  setHasPendingDocumentSync(false);
2752
3008
  }
@@ -2797,8 +3053,6 @@ function useRealtimeSession(options) {
2797
3053
  const isSelfAck = parsed.document.updatedByClientId === selfClientId;
2798
3054
  if (!isSelfAck) {
2799
3055
  outboundInFlightRef.current = null;
2800
- queuedItemsRef.current = localDraftRef.current?.items ?? null;
2801
- setHasPendingDocumentSync(queuedItemsRef.current != null);
2802
3056
  resolveAuthoritativeDocumentRef.current(
2803
3057
  sanitizeRealtimeSnapshot(parsed.document)
2804
3058
  );
@@ -2809,24 +3063,33 @@ function useRealtimeSession(options) {
2809
3063
  applyDocumentRef.current(sanitizeRealtimeSnapshot(parsed.document), {
2810
3064
  suppressSubscriberNotify: shouldSuppress
2811
3065
  });
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) {
3066
+ const board = boardRef.current;
3067
+ const stillPending = board ? getLocallyPendingItemIds(board).size > 0 : false;
3068
+ if (stillPending) {
3069
+ const mergedItems = board ? readVectorItems(board.yItems) : [];
3070
+ setLocalDraftRef.current({
3071
+ roomId,
3072
+ baseRevision: currentRevisionRef.current,
3073
+ items: mergedItems,
3074
+ updatedAt: nowMs(),
3075
+ yDocState: board ? encodeYDocState(board) : void 0,
3076
+ pendingIds: board ? Array.from(getLocallyPendingItemIds(board)) : void 0
3077
+ });
3078
+ queuedDirtyRef.current = true;
3079
+ setHasPendingDocumentSync(true);
3080
+ scheduleDocumentFlushRef.current();
3081
+ } else {
3082
+ queuedDirtyRef.current = false;
2820
3083
  clearLocalDraftRef.current();
3084
+ setHasPendingDocumentSync(false);
2821
3085
  }
2822
- setHasPendingDocumentSync(queuedItemsRef.current != null);
2823
3086
  setConflictStateRef.current(null);
2824
3087
  return;
2825
3088
  }
2826
3089
  if (parsed.type === "document:resync-required") {
2827
3090
  outboundInFlightRef.current = null;
2828
- queuedItemsRef.current = localDraftRef.current?.items ?? null;
2829
- setHasPendingDocumentSync(queuedItemsRef.current != null);
3091
+ queuedDirtyRef.current = localDraftRef.current != null;
3092
+ setHasPendingDocumentSync(queuedDirtyRef.current);
2830
3093
  updateConnectionRef.current((prev) => ({
2831
3094
  ...prev,
2832
3095
  lastError: parsed.reason
@@ -2901,14 +3164,39 @@ function useRealtimeSession(options) {
2901
3164
  () => () => {
2902
3165
  clearDocumentFlushSchedule();
2903
3166
  clearDraftPersistSchedule();
3167
+ if (boardRef.current) {
3168
+ boardRef.current.doc.destroy();
3169
+ boardRef.current = null;
3170
+ }
2904
3171
  },
2905
3172
  [clearDocumentFlushSchedule, clearDraftPersistSchedule]
2906
3173
  );
2907
3174
  const flushDocumentSync = useCallback(async () => {
3175
+ const board = boardRef.current;
3176
+ const pendingLocal = pendingLocalItemsRef.current;
3177
+ if (board && pendingLocal) {
3178
+ pendingLocalItemsRef.current = null;
3179
+ applyLocalItemsToYDoc(board, {
3180
+ items: pendingLocal,
3181
+ origin: ORIGIN_LOCAL
3182
+ });
3183
+ const mergedItems = readVectorItems(board.yItems);
3184
+ const pendingIds = getLocallyPendingItemIds(board);
3185
+ if (pendingIds.size > 0) {
3186
+ setLocalDraftRef.current({
3187
+ roomId,
3188
+ baseRevision: currentRevisionRef.current,
3189
+ items: mergedItems,
3190
+ updatedAt: nowMs(),
3191
+ yDocState: encodeYDocState(board),
3192
+ pendingIds: Array.from(pendingIds)
3193
+ });
3194
+ }
3195
+ }
2908
3196
  persistLocalDraft();
2909
3197
  if (!connection.connected) return;
2910
3198
  flushQueuedDocument();
2911
- }, [connection.connected, flushQueuedDocument, persistLocalDraft]);
3199
+ }, [connection.connected, flushQueuedDocument, persistLocalDraft, roomId]);
2912
3200
  const remoteAdapter = useMemo(
2913
3201
  () => ({
2914
3202
  subscribe(onItems) {