@korajs/react 0.5.0 → 0.6.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/index.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // src/context/kora-context.ts
2
- import { createContext, createElement, useContext, useEffect, useState } from "react";
2
+ import { QueryStoreCache } from "@korajs/store";
3
+ import { createContext, createElement, useContext, useEffect, useMemo, useRef, useState } from "react";
3
4
  var KoraContext = createContext(null);
4
5
  function KoraProvider({
5
6
  app,
@@ -12,6 +13,10 @@ function KoraProvider({
12
13
  const [resolvedSync, setResolvedSync] = useState(syncEngine ?? null);
13
14
  const [ready, setReady] = useState(!app);
14
15
  const [initError, setInitError] = useState(null);
16
+ const fallbackQueryStoreCache = useRef(null);
17
+ if (!fallbackQueryStoreCache.current) {
18
+ fallbackQueryStoreCache.current = new QueryStoreCache();
19
+ }
15
20
  useEffect(() => {
16
21
  if (!app) return;
17
22
  let cancelled = false;
@@ -30,6 +35,26 @@ function KoraProvider({
30
35
  cancelled = true;
31
36
  };
32
37
  }, [app]);
38
+ useEffect(() => {
39
+ return () => {
40
+ if (!app) {
41
+ fallbackQueryStoreCache.current?.clear();
42
+ }
43
+ };
44
+ }, [app]);
45
+ const contextValue = useMemo(() => {
46
+ if (!resolvedStore) {
47
+ return null;
48
+ }
49
+ return {
50
+ store: resolvedStore,
51
+ syncEngine: resolvedSync,
52
+ app: app ?? null,
53
+ events: app?.events ?? null,
54
+ subscribeSyncStatus: app?.sync?.subscribeStatus ?? null,
55
+ queryStoreCache: app && typeof app.getQueryStoreCache === "function" ? app.getQueryStoreCache() : fallbackQueryStoreCache.current
56
+ };
57
+ }, [resolvedStore, resolvedSync, app]);
33
58
  if (initError) {
34
59
  return createElement(
35
60
  "div",
@@ -41,19 +66,12 @@ function KoraProvider({
41
66
  if (!ready) {
42
67
  return fallback ?? null;
43
68
  }
44
- if (!resolvedStore) {
69
+ if (!contextValue) {
45
70
  throw new Error(
46
71
  'KoraProvider requires either an "app" or "store" prop. Pass a KoraApp from createApp() or a Store instance.'
47
72
  );
48
73
  }
49
- const value = {
50
- store: resolvedStore,
51
- syncEngine: resolvedSync,
52
- app: app ?? null,
53
- events: app?.events ?? null,
54
- subscribeSyncStatus: app?.sync?.subscribeStatus ?? null
55
- };
56
- return createElement(KoraContext.Provider, { value }, children);
74
+ return createElement(KoraContext.Provider, { value: contextValue }, children);
57
75
  }
58
76
  function useKoraContext() {
59
77
  const context = useContext(KoraContext);
@@ -77,126 +95,35 @@ function useApp() {
77
95
  }
78
96
 
79
97
  // src/hooks/use-query.ts
80
- import { useMemo, useRef, useSyncExternalStore } from "react";
81
-
82
- // src/query-store/query-store.ts
98
+ import { assertQueryReady } from "@korajs/store";
99
+ import { useEffect as useEffect2, useRef as useRef2, useState as useState2, useSyncExternalStore } from "react";
83
100
  var EMPTY_ARRAY = Object.freeze([]);
84
- var QueryStore = class {
85
- snapshot = EMPTY_ARRAY;
86
- listeners = /* @__PURE__ */ new Set();
87
- unsubscribeQuery = null;
88
- active = false;
89
- queryBuilder;
90
- constructor(queryBuilder) {
91
- this.queryBuilder = queryBuilder;
92
- }
93
- /**
94
- * Subscribe to snapshot changes. Compatible with useSyncExternalStore.
95
- *
96
- * Lazily starts the underlying query subscription when the first listener
97
- * attaches, and stops it when the last listener detaches. This allows
98
- * React StrictMode to unmount/remount without permanently killing the subscription.
99
- *
100
- * @returns Unsubscribe function
101
- */
102
- subscribe = (onStoreChange) => {
103
- this.listeners.add(onStoreChange);
104
- if (!this.active) {
105
- this.startSubscription();
106
- }
107
- return () => {
108
- this.listeners.delete(onStoreChange);
109
- if (this.listeners.size === 0) {
110
- this.stopSubscription();
111
- }
112
- };
113
- };
114
- /**
115
- * Get the current snapshot synchronously. Compatible with useSyncExternalStore.
116
- * Returns EMPTY_ARRAY before the first async fetch completes.
117
- */
118
- getSnapshot = () => {
119
- return this.snapshot;
120
- };
121
- /**
122
- * Clean up the underlying subscription and release resources.
123
- * Called by useMemo when the query descriptor changes.
124
- */
125
- destroy() {
126
- this.stopSubscription();
127
- this.listeners.clear();
128
- this.snapshot = EMPTY_ARRAY;
129
- }
130
- startSubscription() {
131
- this.active = true;
132
- this.unsubscribeQuery = this.queryBuilder.subscribe((results) => {
133
- if (!this.active) return;
134
- const newSnapshot = Object.freeze([...results]);
135
- this.snapshot = newSnapshot;
136
- this.notifyListeners();
137
- });
138
- }
139
- stopSubscription() {
140
- this.active = false;
141
- if (this.unsubscribeQuery) {
142
- this.unsubscribeQuery();
143
- this.unsubscribeQuery = null;
144
- }
145
- }
146
- notifyListeners() {
147
- for (const listener of this.listeners) {
148
- listener();
149
- }
150
- }
151
- };
152
-
153
- // src/hooks/use-query.ts
154
- var APP_NOT_READY_CODE = "APP_NOT_READY";
155
- function assertQueryReady(query) {
156
- const descriptor = query.getDescriptor();
157
- if (descriptor.collection === "__pending__") {
158
- const err = new Error(
159
- "Cannot use useQuery() before app.ready. Await app.ready or wrap your UI in <KoraProvider app={app}>."
160
- );
161
- err.name = "AppNotReadyError";
162
- Object.assign(err, { code: APP_NOT_READY_CODE });
163
- throw err;
164
- }
165
- }
166
- var EMPTY_ARRAY2 = Object.freeze([]);
167
101
  var noopSubscribe = (_onStoreChange) => {
168
102
  return () => {
169
103
  };
170
104
  };
171
105
  function useQuery(query, options) {
106
+ const { queryStoreCache } = useKoraContext();
172
107
  const enabled = options?.enabled !== false;
173
- if (enabled) {
174
- assertQueryReady(query);
175
- }
176
108
  const descriptorKey = JSON.stringify(query.getDescriptor());
177
- const queryStoreRef = useRef(null);
178
- const prevKeyRef = useRef(null);
179
- const queryStore = useMemo(() => {
109
+ const queryRef = useRef2(query);
110
+ queryRef.current = query;
111
+ const [queryStore, setQueryStore] = useState2(null);
112
+ useEffect2(() => {
180
113
  if (!enabled) {
181
- if (queryStoreRef.current) {
182
- queryStoreRef.current.destroy();
183
- queryStoreRef.current = null;
184
- }
185
- prevKeyRef.current = null;
186
- return null;
187
- }
188
- if (prevKeyRef.current !== descriptorKey) {
189
- if (queryStoreRef.current) {
190
- queryStoreRef.current.destroy();
191
- }
192
- const newStore = new QueryStore(query);
193
- queryStoreRef.current = newStore;
194
- prevKeyRef.current = descriptorKey;
195
- return newStore;
114
+ setQueryStore(null);
115
+ return;
196
116
  }
197
- return queryStoreRef.current;
198
- }, [descriptorKey, enabled, query]);
199
- const disabledGetSnapshot = () => EMPTY_ARRAY2;
117
+ const currentQuery = queryRef.current;
118
+ assertQueryReady(currentQuery);
119
+ const store = queryStoreCache.getOrCreate(currentQuery);
120
+ setQueryStore(store);
121
+ return () => {
122
+ queryStoreCache.release(currentQuery);
123
+ setQueryStore(null);
124
+ };
125
+ }, [descriptorKey, enabled, queryStoreCache]);
126
+ const disabledGetSnapshot = () => EMPTY_ARRAY;
200
127
  return useSyncExternalStore(
201
128
  queryStore ? queryStore.subscribe : noopSubscribe,
202
129
  queryStore ? queryStore.getSnapshot : disabledGetSnapshot
@@ -204,144 +131,85 @@ function useQuery(query, options) {
204
131
  }
205
132
 
206
133
  // src/hooks/use-mutation.ts
207
- import { useCallback, useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
208
- function useMutation(mutationFn, options) {
209
- const [state, setState] = useState2({
210
- isLoading: false,
211
- error: null
212
- });
213
- const mountedRef = useRef2(true);
214
- useEffect2(() => {
215
- mountedRef.current = true;
134
+ import { createMutationController } from "@korajs/core/bindings";
135
+ import { useRef as useRef4, useSyncExternalStore as useSyncExternalStore2 } from "react";
136
+
137
+ // src/hooks/use-controller.ts
138
+ import { useEffect as useEffect3, useReducer, useRef as useRef3 } from "react";
139
+ function useController(create, destroy, deps) {
140
+ const controllerRef = useRef3(null);
141
+ const createRef = useRef3(create);
142
+ createRef.current = create;
143
+ const [, forceRender] = useReducer((count) => count + 1, 0);
144
+ const getController = useRef3(() => {
145
+ if (controllerRef.current === null) {
146
+ controllerRef.current = createRef.current();
147
+ }
148
+ return controllerRef.current;
149
+ }).current;
150
+ useEffect3(() => {
151
+ if (controllerRef.current === null) {
152
+ controllerRef.current = createRef.current();
153
+ forceRender();
154
+ }
216
155
  return () => {
217
- mountedRef.current = false;
156
+ const controller = controllerRef.current;
157
+ controllerRef.current = null;
158
+ if (controller !== null) {
159
+ destroy(controller);
160
+ }
218
161
  };
219
- }, []);
220
- const fnRef = useRef2(mutationFn);
162
+ }, deps);
163
+ return getController;
164
+ }
165
+
166
+ // src/hooks/use-mutation.ts
167
+ function useMutation(mutationFn, options) {
168
+ const fnRef = useRef4(mutationFn);
221
169
  fnRef.current = mutationFn;
222
- const optionsRef = useRef2(options);
170
+ const optionsRef = useRef4(options);
223
171
  optionsRef.current = options;
224
- const mutateAsync = useCallback(async (...args) => {
225
- const opts = optionsRef.current;
226
- let context;
227
- if (mountedRef.current) {
228
- setState({ isLoading: true, error: null });
229
- }
230
- try {
231
- if (opts?.onMutate) {
232
- context = await opts.onMutate(...args);
233
- }
234
- const result = await fnRef.current(...args);
235
- if (mountedRef.current) {
236
- setState({ isLoading: false, error: null });
237
- }
238
- opts?.onSuccess?.(result, ...args);
239
- opts?.onSettled?.(result, null, ...args);
240
- return result;
241
- } catch (err) {
242
- const error = err instanceof Error ? err : new Error(String(err));
243
- if (context !== void 0 && opts?.onRollback) {
244
- await opts.onRollback(context, ...args);
245
- }
246
- if (mountedRef.current) {
247
- setState({ isLoading: false, error });
248
- }
249
- opts?.onError?.(error, ...args);
250
- opts?.onSettled?.(void 0, error, ...args);
251
- throw error;
252
- }
253
- }, []);
254
- const mutate = useCallback(
255
- (...args) => {
256
- mutateAsync(...args).catch(() => {
257
- });
258
- },
259
- [mutateAsync]
172
+ const getController = useController(
173
+ () => createMutationController({
174
+ mutationFn: (...args) => fnRef.current(...args),
175
+ resolveOptions: () => optionsRef.current
176
+ }),
177
+ (controller) => controller.destroy(),
178
+ []
179
+ );
180
+ const state = useSyncExternalStore2(
181
+ (onStoreChange) => getController().subscribe(onStoreChange),
182
+ () => getController().getSnapshot(),
183
+ () => getController().getSnapshot()
260
184
  );
261
- const reset = useCallback(() => {
262
- if (mountedRef.current) {
263
- setState({ isLoading: false, error: null });
264
- }
265
- }, []);
266
185
  return {
267
- mutate,
268
- mutateAsync,
186
+ mutate: (...args) => getController().mutate(...args),
187
+ mutateAsync: (...args) => getController().mutateAsync(...args),
269
188
  isLoading: state.isLoading,
270
189
  error: state.error,
271
- reset
190
+ reset: () => getController().reset()
272
191
  };
273
192
  }
274
193
 
275
194
  // src/hooks/use-sync-status.ts
276
- import { useCallback as useCallback2, useRef as useRef3, useSyncExternalStore as useSyncExternalStore2 } from "react";
277
- var OFFLINE_STATUS = Object.freeze({
278
- status: "offline",
279
- pendingOperations: 0,
280
- lastSyncedAt: null,
281
- lastSuccessfulPush: null,
282
- lastSuccessfulPull: null,
283
- conflicts: 0
284
- });
285
- var SYNC_STATUS_EVENT_TYPES = [
286
- "sync:connected",
287
- "sync:disconnected",
288
- "sync:schema-mismatch",
289
- "sync:auth-failed",
290
- "sync:sent",
291
- "sync:received",
292
- "sync:acknowledged",
293
- "sync:apply-failed",
294
- "sync:diagnostics",
295
- "sync:initial-sync-progress"
296
- ];
195
+ import { createSyncStatusController } from "@korajs/sync";
196
+ import { useSyncExternalStore as useSyncExternalStore3 } from "react";
297
197
  function useSyncStatus() {
298
198
  const { syncEngine, subscribeSyncStatus, events } = useKoraContext();
299
- const snapshotRef = useRef3(OFFLINE_STATUS);
300
- const serializedRef = useRef3(JSON.stringify(OFFLINE_STATUS));
301
- const subscribe = useCallback2(
302
- (onStoreChange) => {
303
- if (subscribeSyncStatus) {
304
- return subscribeSyncStatus((status) => {
305
- snapshotRef.current = status;
306
- serializedRef.current = JSON.stringify(status);
307
- onStoreChange();
308
- });
309
- }
310
- if (!syncEngine) {
311
- return () => {
312
- };
313
- }
314
- const refresh = () => {
315
- const newStatus = syncEngine.getStatus();
316
- const newSerialized = JSON.stringify(newStatus);
317
- if (newSerialized !== serializedRef.current) {
318
- snapshotRef.current = newStatus;
319
- serializedRef.current = newSerialized;
320
- onStoreChange();
321
- }
322
- };
323
- if (events) {
324
- const unsubs = SYNC_STATUS_EVENT_TYPES.map((type) => events.on(type, refresh));
325
- refresh();
326
- return () => {
327
- for (const unsub of unsubs) {
328
- unsub();
329
- }
330
- };
331
- }
332
- refresh();
333
- return () => {
334
- };
335
- },
199
+ const getController = useController(
200
+ () => createSyncStatusController({
201
+ syncEngine,
202
+ subscribeSyncStatus,
203
+ events: subscribeSyncStatus ? null : events
204
+ }),
205
+ (controller) => controller.destroy(),
336
206
  [syncEngine, subscribeSyncStatus, events]
337
207
  );
338
- const getSnapshot = useCallback2(() => {
339
- if (subscribeSyncStatus) {
340
- return snapshotRef.current;
341
- }
342
- return syncEngine ? syncEngine.getStatus() : OFFLINE_STATUS;
343
- }, [syncEngine, subscribeSyncStatus]);
344
- return useSyncExternalStore2(subscribe, getSnapshot);
208
+ return useSyncExternalStore3(
209
+ (onStoreChange) => getController().subscribe(onStoreChange),
210
+ () => getController().getSnapshot(),
211
+ () => getController().getSnapshot()
212
+ );
345
213
  }
346
214
 
347
215
  // src/hooks/use-collection.ts
@@ -354,303 +222,118 @@ function useCollection(name) {
354
222
  }
355
223
 
356
224
  // src/hooks/use-rich-text.ts
357
- import { encodeRichtext } from "@korajs/store";
358
- import { useCallback as useCallback3, useEffect as useEffect3, useMemo as useMemo3, useRef as useRef4, useState as useState3 } from "react";
359
- import * as Y from "yjs";
360
- var LOAD_ORIGIN = "kora-load";
361
- var REMOTE_ORIGIN = "kora-remote";
362
- var DOC_CHANNEL_ORIGIN = "kora-doc-channel";
363
- var TEXT_KEY = "content";
364
- var COMPACT_AFTER_DELTAS = 20;
365
- var PERSIST_DEBOUNCE_MS = 400;
225
+ import { asRichTextSyncEngine, createRichTextController } from "@korajs/store";
226
+ import { useCallback, useEffect as useEffect4, useMemo as useMemo3, useSyncExternalStore as useSyncExternalStore4 } from "react";
366
227
  function useRichText(collectionName, recordId, fieldName, options) {
367
228
  const { store, syncEngine } = useKoraContext();
368
- const collection = useMemo3(
369
- () => store.collection(collectionName),
370
- [store, collectionName]
229
+ const collection = useMemo3(() => store.collection(collectionName), [store, collectionName]);
230
+ const getController = useController(
231
+ () => createRichTextController({
232
+ collection,
233
+ collectionName,
234
+ recordId,
235
+ fieldName,
236
+ store,
237
+ syncEngine: asRichTextSyncEngine(syncEngine),
238
+ useDocChannel: options?.useDocChannel,
239
+ user: options?.user
240
+ }),
241
+ (controller) => controller.destroy(),
242
+ [collection, collectionName, fieldName, options?.useDocChannel, recordId, store, syncEngine]
371
243
  );
372
- const [doc] = useState3(() => new Y.Doc());
373
- const [ready, setReady] = useState3(false);
374
- const [error, setError] = useState3(null);
375
- const [canUndo, setCanUndo] = useState3(false);
376
- const [canRedo, setCanRedo] = useState3(false);
377
- const [cursors, setCursors] = useState3([]);
378
- const baseUpdateRef = useRef4(null);
379
- const pendingDeltasRef = useRef4([]);
380
- const docChannelActiveRef = useRef4(false);
381
- const persistTimerRef = useRef4(null);
382
- const userRef = useRef4(options?.user);
383
- useEffect3(() => {
384
- userRef.current = options?.user;
385
- }, [options?.user]);
386
- const text = useMemo3(() => doc.getText(TEXT_KEY), [doc]);
387
- const undoManager = useMemo3(() => new Y.UndoManager(text), [text]);
388
- const syncHistoryState = useCallback3(() => {
389
- setCanUndo(undoManager.undoStack.length > 0);
390
- setCanRedo(undoManager.redoStack.length > 0);
391
- }, [undoManager]);
392
- const undo = useCallback3(() => {
393
- undoManager.undo();
394
- syncHistoryState();
395
- }, [syncHistoryState, undoManager]);
396
- const redo = useCallback3(() => {
397
- undoManager.redo();
398
- syncHistoryState();
399
- }, [syncHistoryState, undoManager]);
400
- const resolveAwarenessUser = useCallback3(() => {
401
- if (userRef.current) {
402
- return userRef.current;
403
- }
404
- if (syncEngine) {
405
- const existing = syncEngine.getAwarenessManager().getLocalState()?.user;
406
- if (existing) {
407
- return existing;
408
- }
409
- }
410
- return {
411
- name: "Anonymous",
412
- color: "#6366f1"
413
- };
414
- }, [syncEngine]);
415
- const setCursor = useCallback3(
416
- (anchor, head) => {
417
- if (!syncEngine) {
418
- return;
419
- }
420
- const awareness = syncEngine.getAwarenessManager();
421
- const state = {
422
- user: resolveAwarenessUser(),
423
- cursor: {
424
- collection: collectionName,
425
- recordId,
426
- field: fieldName,
427
- anchor,
428
- head
429
- }
430
- };
431
- awareness.setLocalState(state);
432
- },
433
- [collectionName, fieldName, recordId, resolveAwarenessUser, syncEngine]
244
+ useEffect4(() => {
245
+ getController().setUser(options?.user);
246
+ }, [getController, options?.user]);
247
+ const snapshot = useSyncExternalStore4(
248
+ (onStoreChange) => getController().subscribe(onStoreChange),
249
+ () => getController().getSnapshot(),
250
+ () => getController().getSnapshot()
434
251
  );
435
- const clearCursor = useCallback3(() => {
436
- if (!syncEngine) {
437
- return;
438
- }
439
- const awareness = syncEngine.getAwarenessManager();
440
- const current = awareness.getLocalState();
441
- if (!current?.cursor) {
442
- return;
443
- }
444
- if (current.cursor.collection !== collectionName || current.cursor.recordId !== recordId || current.cursor.field !== fieldName) {
445
- return;
446
- }
447
- awareness.setLocalState({ user: current.user });
448
- }, [collectionName, fieldName, recordId, syncEngine]);
449
- const applyRemoteSnapshot = useCallback3(
450
- (value) => {
451
- const encoded = encodeRichtextInput(value);
452
- if (!encoded) {
453
- return;
454
- }
455
- const currentSnapshot = Y.encodeStateAsUpdate(doc);
456
- if (updatesEqual(currentSnapshot, encoded)) {
457
- return;
458
- }
459
- Y.applyUpdate(doc, encoded, REMOTE_ORIGIN);
460
- baseUpdateRef.current = encoded;
461
- pendingDeltasRef.current = [];
462
- syncHistoryState();
463
- },
464
- [doc, syncHistoryState]
252
+ const undo = useCallback(() => {
253
+ getController().undo();
254
+ }, [getController]);
255
+ const redo = useCallback(() => {
256
+ getController().redo();
257
+ }, [getController]);
258
+ const setCursor = useCallback(
259
+ (anchor, head) => getController().setCursor(anchor, head),
260
+ [getController]
465
261
  );
466
- useEffect3(() => {
467
- let disposed = false;
468
- const initialize = async () => {
469
- setReady(false);
470
- setError(null);
471
- try {
472
- const record = await collection.findById(recordId);
473
- if (disposed) return;
474
- doc.transact(() => {
475
- const target = doc.getText(TEXT_KEY);
476
- target.delete(0, target.length);
477
- }, LOAD_ORIGIN);
478
- const encoded = encodeRichtextInput(record?.[fieldName]);
479
- baseUpdateRef.current = encoded;
480
- pendingDeltasRef.current = [];
481
- if (encoded) {
482
- Y.applyUpdate(doc, encoded, LOAD_ORIGIN);
483
- }
484
- const channel = syncEngine?.getRichtextDocChannel?.();
485
- docChannelActiveRef.current = channel?.shouldUseChannel(encoded?.length ?? 0, options?.useDocChannel) ?? false;
486
- setReady(true);
487
- } catch (cause) {
488
- if (disposed) return;
489
- setError(cause instanceof Error ? cause : new Error(String(cause)));
490
- }
491
- };
492
- const flushPersist = async () => {
493
- const snapshot = composeRichtextSnapshot(baseUpdateRef.current, pendingDeltasRef.current);
494
- if (pendingDeltasRef.current.length >= COMPACT_AFTER_DELTAS) {
495
- baseUpdateRef.current = snapshot;
496
- pendingDeltasRef.current = [];
497
- }
498
- try {
499
- await collection.update(recordId, {
500
- [fieldName]: snapshot
501
- });
502
- } catch (cause) {
503
- if (!disposed) {
504
- setError(cause instanceof Error ? cause : new Error(String(cause)));
505
- }
506
- }
507
- };
508
- const schedulePersist = () => {
509
- if (persistTimerRef.current) {
510
- clearTimeout(persistTimerRef.current);
511
- }
512
- persistTimerRef.current = setTimeout(() => {
513
- persistTimerRef.current = null;
514
- void flushPersist();
515
- }, PERSIST_DEBOUNCE_MS);
516
- };
517
- const persist = (_update, origin) => {
518
- syncHistoryState();
519
- if (origin === LOAD_ORIGIN || origin === REMOTE_ORIGIN || origin === DOC_CHANNEL_ORIGIN) {
520
- return;
521
- }
522
- pendingDeltasRef.current.push(_update);
523
- if (docChannelActiveRef.current && syncEngine) {
524
- syncEngine.getRichtextDocChannel().send(collectionName, recordId, fieldName, _update);
525
- schedulePersist();
526
- return;
527
- }
528
- void flushPersist();
529
- };
530
- doc.on("update", persist);
531
- void initialize();
532
- syncHistoryState();
533
- return () => {
534
- disposed = true;
535
- if (persistTimerRef.current) {
536
- clearTimeout(persistTimerRef.current);
537
- persistTimerRef.current = null;
538
- }
539
- doc.off("update", persist);
540
- undoManager.destroy();
541
- baseUpdateRef.current = null;
542
- pendingDeltasRef.current = [];
543
- docChannelActiveRef.current = false;
544
- };
545
- }, [
546
- collection,
547
- collectionName,
548
- doc,
549
- fieldName,
550
- options?.useDocChannel,
551
- recordId,
552
- syncEngine,
553
- syncHistoryState,
554
- undoManager
555
- ]);
556
- useEffect3(() => {
557
- if (!ready || !syncEngine || !docChannelActiveRef.current) {
558
- return;
559
- }
560
- const channel = syncEngine.getRichtextDocChannel();
561
- return channel.subscribe(collectionName, recordId, fieldName, (update) => {
562
- Y.applyUpdate(doc, update, DOC_CHANNEL_ORIGIN);
563
- syncHistoryState();
564
- });
565
- }, [collectionName, doc, fieldName, ready, recordId, syncEngine, syncHistoryState]);
566
- useEffect3(() => {
567
- if (!ready) {
568
- return;
569
- }
570
- const unsubscribe = store.collection(collectionName).where({ id: recordId }).subscribe((results) => {
571
- const record = results[0];
572
- if (!record) {
573
- return;
574
- }
575
- applyRemoteSnapshot(record[fieldName]);
576
- });
577
- return unsubscribe;
578
- }, [applyRemoteSnapshot, collectionName, fieldName, ready, recordId, store]);
579
- useEffect3(() => {
580
- if (!syncEngine) return;
262
+ const clearCursor = useCallback(() => getController().clearCursor(), [getController]);
263
+ return buildResult(getController(), snapshot, undo, redo, setCursor, clearCursor);
264
+ }
265
+ function buildResult(controller, snapshot, undo, redo, setCursor, clearCursor) {
266
+ return {
267
+ doc: controller.doc,
268
+ text: controller.text,
269
+ undo,
270
+ redo,
271
+ canUndo: snapshot.canUndo,
272
+ canRedo: snapshot.canRedo,
273
+ ready: snapshot.ready,
274
+ error: snapshot.error,
275
+ cursors: [...snapshot.cursors],
276
+ setCursor,
277
+ clearCursor
278
+ };
279
+ }
280
+
281
+ // src/hooks/use-presence.ts
282
+ import { useEffect as useEffect5 } from "react";
283
+ function usePresence(user) {
284
+ const { syncEngine } = useKoraContext();
285
+ const name = user?.name ?? null;
286
+ const color = user?.color ?? null;
287
+ const avatar = user?.avatar ?? null;
288
+ useEffect5(() => {
289
+ if (!syncEngine || !name || !color) return;
581
290
  const awareness = syncEngine.getAwarenessManager();
582
- const localClientId = awareness.clientId;
583
- const updateCursors = () => {
584
- const states = awareness.getStates();
585
- const fieldCursors = [];
586
- for (const [clientId, state] of states) {
587
- if (clientId === localClientId) continue;
588
- if (!state.cursor) continue;
589
- if (state.cursor.collection !== collectionName || state.cursor.recordId !== recordId || state.cursor.field !== fieldName) {
590
- continue;
591
- }
592
- fieldCursors.push({
593
- clientId,
594
- userName: state.user.name,
595
- color: state.user.color,
596
- anchor: state.cursor.anchor,
597
- head: state.cursor.head
598
- });
599
- }
600
- setCursors(fieldCursors);
601
- };
602
- const unsubscribe = awareness.on("change", () => {
603
- updateCursors();
291
+ awareness.setLocalState({
292
+ user: { name, color, avatar: avatar ?? void 0 }
604
293
  });
605
- updateCursors();
606
294
  return () => {
607
- unsubscribe();
608
- clearCursor();
295
+ awareness.setLocalState(null);
609
296
  };
610
- }, [clearCursor, collectionName, fieldName, recordId, syncEngine]);
611
- return { doc, text, undo, redo, canUndo, canRedo, ready, error, cursors, setCursor, clearCursor };
612
- }
613
- function encodeRichtextInput(value) {
614
- if (value === null || value === void 0) {
615
- return null;
616
- }
617
- try {
618
- return encodeRichtext(value);
619
- } catch {
620
- if (typeof value === "string") {
621
- const fallbackDoc = new Y.Doc();
622
- fallbackDoc.getText(TEXT_KEY).insert(0, value);
623
- return Y.encodeStateAsUpdate(fallbackDoc);
624
- }
625
- throw new Error("Richtext record value must be a string, Uint8Array, ArrayBuffer, or null.");
626
- }
297
+ }, [syncEngine, name, color, avatar]);
627
298
  }
628
- function composeRichtextSnapshot(base, deltas) {
629
- const mergedDoc = new Y.Doc();
630
- if (base) {
631
- Y.applyUpdate(mergedDoc, base);
632
- }
633
- for (const delta of deltas) {
634
- Y.applyUpdate(mergedDoc, delta);
635
- }
636
- return Y.encodeStateAsUpdate(mergedDoc);
637
- }
638
- function updatesEqual(left, right) {
639
- if (left.length !== right.length) {
640
- return false;
641
- }
642
- for (let index = 0; index < left.length; index++) {
643
- if (left[index] !== right[index]) {
644
- return false;
299
+
300
+ // src/hooks/use-collaborators.ts
301
+ import { subscribeRemoteAwarenessStates } from "@korajs/sync";
302
+ import { useCallback as useCallback2, useEffect as useEffect6, useRef as useRef5, useSyncExternalStore as useSyncExternalStore5 } from "react";
303
+ var EMPTY_ARRAY2 = [];
304
+ function useCollaborators() {
305
+ const { syncEngine } = useKoraContext();
306
+ const snapshotRef = useRef5(EMPTY_ARRAY2);
307
+ const subscribe = useCallback2(
308
+ (onStoreChange) => {
309
+ if (!syncEngine) {
310
+ snapshotRef.current = EMPTY_ARRAY2;
311
+ return () => {
312
+ };
313
+ }
314
+ const awareness = syncEngine.getAwarenessManager();
315
+ return subscribeRemoteAwarenessStates(awareness, (states) => {
316
+ snapshotRef.current = states;
317
+ onStoreChange();
318
+ });
319
+ },
320
+ [syncEngine]
321
+ );
322
+ const getSnapshot = useCallback2(() => snapshotRef.current, []);
323
+ useEffect6(() => {
324
+ if (!syncEngine) {
325
+ snapshotRef.current = EMPTY_ARRAY2;
645
326
  }
646
- }
647
- return true;
327
+ }, [syncEngine]);
328
+ return useSyncExternalStore5(subscribe, getSnapshot);
648
329
  }
649
330
  export {
650
331
  KoraProvider,
651
332
  useApp,
333
+ useCollaborators,
652
334
  useCollection,
653
335
  useMutation,
336
+ usePresence,
654
337
  useQuery,
655
338
  useRichText,
656
339
  useSyncStatus