@korajs/react 0.3.2 → 0.5.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/index.js CHANGED
@@ -8,12 +8,8 @@ function KoraProvider({
8
8
  fallback,
9
9
  children
10
10
  }) {
11
- const [resolvedStore, setResolvedStore] = useState(
12
- store ?? null
13
- );
14
- const [resolvedSync, setResolvedSync] = useState(
15
- syncEngine ?? null
16
- );
11
+ const [resolvedStore, setResolvedStore] = useState(store ?? null);
12
+ const [resolvedSync, setResolvedSync] = useState(syncEngine ?? null);
17
13
  const [ready, setReady] = useState(!app);
18
14
  const [initError, setInitError] = useState(null);
19
15
  useEffect(() => {
@@ -52,7 +48,10 @@ function KoraProvider({
52
48
  }
53
49
  const value = {
54
50
  store: resolvedStore,
55
- syncEngine: resolvedSync
51
+ syncEngine: resolvedSync,
52
+ app: app ?? null,
53
+ events: app?.events ?? null,
54
+ subscribeSyncStatus: app?.sync?.subscribeStatus ?? null
56
55
  };
57
56
  return createElement(KoraContext.Provider, { value }, children);
58
57
  }
@@ -66,6 +65,17 @@ function useKoraContext() {
66
65
  return context;
67
66
  }
68
67
 
68
+ // src/hooks/use-app.ts
69
+ function useApp() {
70
+ const { app } = useKoraContext();
71
+ if (!app) {
72
+ throw new Error(
73
+ 'useApp() requires KoraProvider to be initialized with an "app" prop. Pass your createApp() result to <KoraProvider app={app}>.'
74
+ );
75
+ }
76
+ return app;
77
+ }
78
+
69
79
  // src/hooks/use-query.ts
70
80
  import { useMemo, useRef, useSyncExternalStore } from "react";
71
81
 
@@ -141,6 +151,18 @@ var QueryStore = class {
141
151
  };
142
152
 
143
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
+ }
144
166
  var EMPTY_ARRAY2 = Object.freeze([]);
145
167
  var noopSubscribe = (_onStoreChange) => {
146
168
  return () => {
@@ -148,6 +170,9 @@ var noopSubscribe = (_onStoreChange) => {
148
170
  };
149
171
  function useQuery(query, options) {
150
172
  const enabled = options?.enabled !== false;
173
+ if (enabled) {
174
+ assertQueryReady(query);
175
+ }
151
176
  const descriptorKey = JSON.stringify(query.getDescriptor());
152
177
  const queryStoreRef = useRef(null);
153
178
  const prevKeyRef = useRef(null);
@@ -180,7 +205,7 @@ function useQuery(query, options) {
180
205
 
181
206
  // src/hooks/use-mutation.ts
182
207
  import { useCallback, useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
183
- function useMutation(mutationFn) {
208
+ function useMutation(mutationFn, options) {
184
209
  const [state, setState] = useState2({
185
210
  isLoading: false,
186
211
  error: null
@@ -194,21 +219,35 @@ function useMutation(mutationFn) {
194
219
  }, []);
195
220
  const fnRef = useRef2(mutationFn);
196
221
  fnRef.current = mutationFn;
222
+ const optionsRef = useRef2(options);
223
+ optionsRef.current = options;
197
224
  const mutateAsync = useCallback(async (...args) => {
225
+ const opts = optionsRef.current;
226
+ let context;
198
227
  if (mountedRef.current) {
199
228
  setState({ isLoading: true, error: null });
200
229
  }
201
230
  try {
231
+ if (opts?.onMutate) {
232
+ context = await opts.onMutate(...args);
233
+ }
202
234
  const result = await fnRef.current(...args);
203
235
  if (mountedRef.current) {
204
236
  setState({ isLoading: false, error: null });
205
237
  }
238
+ opts?.onSuccess?.(result, ...args);
239
+ opts?.onSettled?.(result, null, ...args);
206
240
  return result;
207
241
  } catch (err) {
208
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
+ }
209
246
  if (mountedRef.current) {
210
247
  setState({ isLoading: false, error });
211
248
  }
249
+ opts?.onError?.(error, ...args);
250
+ opts?.onSettled?.(void 0, error, ...args);
212
251
  throw error;
213
252
  }
214
253
  }, []);
@@ -234,22 +273,45 @@ function useMutation(mutationFn) {
234
273
  }
235
274
 
236
275
  // src/hooks/use-sync-status.ts
237
- import { useCallback as useCallback2, useEffect as useEffect3, useRef as useRef3, useSyncExternalStore as useSyncExternalStore2 } from "react";
238
- var POLL_INTERVAL_MS = 500;
276
+ import { useCallback as useCallback2, useRef as useRef3, useSyncExternalStore as useSyncExternalStore2 } from "react";
239
277
  var OFFLINE_STATUS = Object.freeze({
240
278
  status: "offline",
241
279
  pendingOperations: 0,
242
- lastSyncedAt: null
280
+ lastSyncedAt: null,
281
+ lastSuccessfulPush: null,
282
+ lastSuccessfulPull: null,
283
+ conflicts: 0
243
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
+ ];
244
297
  function useSyncStatus() {
245
- const { syncEngine } = useKoraContext();
298
+ const { syncEngine, subscribeSyncStatus, events } = useKoraContext();
246
299
  const snapshotRef = useRef3(OFFLINE_STATUS);
247
300
  const serializedRef = useRef3(JSON.stringify(OFFLINE_STATUS));
248
301
  const subscribe = useCallback2(
249
302
  (onStoreChange) => {
250
- if (!syncEngine) return () => {
251
- };
252
- const intervalId = setInterval(() => {
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 = () => {
253
315
  const newStatus = syncEngine.getStatus();
254
316
  const newSerialized = JSON.stringify(newStatus);
255
317
  if (newSerialized !== serializedRef.current) {
@@ -257,29 +319,28 @@ function useSyncStatus() {
257
319
  serializedRef.current = newSerialized;
258
320
  onStoreChange();
259
321
  }
260
- }, POLL_INTERVAL_MS);
261
- const initialStatus = syncEngine.getStatus();
262
- const initialSerialized = JSON.stringify(initialStatus);
263
- if (initialSerialized !== serializedRef.current) {
264
- snapshotRef.current = initialStatus;
265
- serializedRef.current = initialSerialized;
266
- onStoreChange();
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
+ };
267
331
  }
332
+ refresh();
268
333
  return () => {
269
- clearInterval(intervalId);
270
334
  };
271
335
  },
272
- [syncEngine]
336
+ [syncEngine, subscribeSyncStatus, events]
273
337
  );
274
338
  const getSnapshot = useCallback2(() => {
275
- return snapshotRef.current;
276
- }, []);
277
- useEffect3(() => {
278
- if (!syncEngine) {
279
- snapshotRef.current = OFFLINE_STATUS;
280
- serializedRef.current = JSON.stringify(OFFLINE_STATUS);
339
+ if (subscribeSyncStatus) {
340
+ return snapshotRef.current;
281
341
  }
282
- }, [syncEngine]);
342
+ return syncEngine ? syncEngine.getStatus() : OFFLINE_STATUS;
343
+ }, [syncEngine, subscribeSyncStatus]);
283
344
  return useSyncExternalStore2(subscribe, getSnapshot);
284
345
  }
285
346
 
@@ -293,21 +354,35 @@ function useCollection(name) {
293
354
  }
294
355
 
295
356
  // src/hooks/use-rich-text.ts
296
- import { useCallback as useCallback3, useEffect as useEffect4, useMemo as useMemo3, useRef as useRef4, useState as useState3 } from "react";
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";
297
359
  import * as Y from "yjs";
298
360
  var LOAD_ORIGIN = "kora-load";
361
+ var REMOTE_ORIGIN = "kora-remote";
362
+ var DOC_CHANNEL_ORIGIN = "kora-doc-channel";
299
363
  var TEXT_KEY = "content";
300
364
  var COMPACT_AFTER_DELTAS = 20;
301
- function useRichText(collectionName, recordId, fieldName) {
302
- const { store } = useKoraContext();
303
- const collection = useMemo3(() => store.collection(collectionName), [store, collectionName]);
365
+ var PERSIST_DEBOUNCE_MS = 400;
366
+ function useRichText(collectionName, recordId, fieldName, options) {
367
+ const { store, syncEngine } = useKoraContext();
368
+ const collection = useMemo3(
369
+ () => store.collection(collectionName),
370
+ [store, collectionName]
371
+ );
304
372
  const [doc] = useState3(() => new Y.Doc());
305
373
  const [ready, setReady] = useState3(false);
306
374
  const [error, setError] = useState3(null);
307
375
  const [canUndo, setCanUndo] = useState3(false);
308
376
  const [canRedo, setCanRedo] = useState3(false);
377
+ const [cursors, setCursors] = useState3([]);
309
378
  const baseUpdateRef = useRef4(null);
310
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]);
311
386
  const text = useMemo3(() => doc.getText(TEXT_KEY), [doc]);
312
387
  const undoManager = useMemo3(() => new Y.UndoManager(text), [text]);
313
388
  const syncHistoryState = useCallback3(() => {
@@ -322,7 +397,73 @@ function useRichText(collectionName, recordId, fieldName) {
322
397
  undoManager.redo();
323
398
  syncHistoryState();
324
399
  }, [syncHistoryState, undoManager]);
325
- useEffect4(() => {
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]
434
+ );
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]
465
+ );
466
+ useEffect3(() => {
326
467
  let disposed = false;
327
468
  const initialize = async () => {
328
469
  setReady(false);
@@ -340,18 +481,15 @@ function useRichText(collectionName, recordId, fieldName) {
340
481
  if (encoded) {
341
482
  Y.applyUpdate(doc, encoded, LOAD_ORIGIN);
342
483
  }
484
+ const channel = syncEngine?.getRichtextDocChannel?.();
485
+ docChannelActiveRef.current = channel?.shouldUseChannel(encoded?.length ?? 0, options?.useDocChannel) ?? false;
343
486
  setReady(true);
344
487
  } catch (cause) {
345
488
  if (disposed) return;
346
489
  setError(cause instanceof Error ? cause : new Error(String(cause)));
347
490
  }
348
491
  };
349
- const persist = async (_update, origin) => {
350
- syncHistoryState();
351
- if (origin === LOAD_ORIGIN) {
352
- return;
353
- }
354
- pendingDeltasRef.current.push(_update);
492
+ const flushPersist = async () => {
355
493
  const snapshot = composeRichtextSnapshot(baseUpdateRef.current, pendingDeltasRef.current);
356
494
  if (pendingDeltasRef.current.length >= COMPACT_AFTER_DELTAS) {
357
495
  baseUpdateRef.current = snapshot;
@@ -367,54 +505,150 @@ function useRichText(collectionName, recordId, fieldName) {
367
505
  }
368
506
  }
369
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
+ };
370
530
  doc.on("update", persist);
371
531
  void initialize();
372
532
  syncHistoryState();
373
533
  return () => {
374
534
  disposed = true;
535
+ if (persistTimerRef.current) {
536
+ clearTimeout(persistTimerRef.current);
537
+ persistTimerRef.current = null;
538
+ }
375
539
  doc.off("update", persist);
376
540
  undoManager.destroy();
377
541
  baseUpdateRef.current = null;
378
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;
581
+ 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();
604
+ });
605
+ updateCursors();
606
+ return () => {
607
+ unsubscribe();
608
+ clearCursor();
379
609
  };
380
- }, [collection, doc, fieldName, recordId, syncHistoryState, undoManager]);
381
- return { doc, text, undo, redo, canUndo, canRedo, ready, error };
610
+ }, [clearCursor, collectionName, fieldName, recordId, syncEngine]);
611
+ return { doc, text, undo, redo, canUndo, canRedo, ready, error, cursors, setCursor, clearCursor };
382
612
  }
383
613
  function encodeRichtextInput(value) {
384
614
  if (value === null || value === void 0) {
385
615
  return null;
386
616
  }
387
- if (typeof value === "string") {
388
- const doc = new Y.Doc();
389
- doc.getText(TEXT_KEY).insert(0, value);
390
- return Y.encodeStateAsUpdate(doc);
391
- }
392
- if (value instanceof Uint8Array) {
393
- return value;
394
- }
395
- if (value instanceof ArrayBuffer) {
396
- return new Uint8Array(value);
397
- }
398
- if (typeof globalThis !== "undefined" && "Buffer" in globalThis) {
399
- const BufferCtor = globalThis.Buffer;
400
- if (BufferCtor.isBuffer(value)) {
401
- return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
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);
402
624
  }
625
+ throw new Error("Richtext record value must be a string, Uint8Array, ArrayBuffer, or null.");
403
626
  }
404
- throw new Error("Richtext record value must be a string, Uint8Array, ArrayBuffer, or null.");
405
627
  }
406
628
  function composeRichtextSnapshot(base, deltas) {
407
- const doc = new Y.Doc();
629
+ const mergedDoc = new Y.Doc();
408
630
  if (base) {
409
- Y.applyUpdate(doc, base);
631
+ Y.applyUpdate(mergedDoc, base);
410
632
  }
411
633
  for (const delta of deltas) {
412
- Y.applyUpdate(doc, delta);
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;
645
+ }
413
646
  }
414
- return Y.encodeStateAsUpdate(doc);
647
+ return true;
415
648
  }
416
649
  export {
417
650
  KoraProvider,
651
+ useApp,
418
652
  useCollection,
419
653
  useMutation,
420
654
  useQuery,