@korajs/react 1.0.0-beta.12 → 1.0.0-beta.14

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/README.md CHANGED
@@ -1,70 +1,78 @@
1
1
  # @korajs/react
2
2
 
3
- React hooks and bindings for Kora.js. Reactive queries that update automatically, optimistic mutations, sync status tracking, and rich text editing support.
3
+ React bindings for Kora.js: a provider, reactive queries, mutations, sync status, rich text and
4
+ presence. Hooks use `useSyncExternalStore`, so they are safe in concurrent rendering, StrictMode and
5
+ server rendering.
4
6
 
5
7
  ## Install
6
8
 
7
9
  ```bash
8
- pnpm add korajs @korajs/react
10
+ pnpm add korajs@beta @korajs/react@beta
9
11
  ```
10
12
 
11
13
  ## Usage
12
14
 
13
- ### Provider
15
+ <!-- docs-check-prelude
16
+ import { createApp, defineSchema, t } from 'korajs'
17
+ const schema = defineSchema({
18
+ version: 1,
19
+ collections: { todos: { fields: { title: t.string(), completed: t.boolean().default(false), createdAt: t.timestamp().auto() } } },
20
+ })
21
+ const app = createApp({ schema })
22
+ -->
14
23
 
15
24
  ```tsx
16
- import { KoraProvider } from '@korajs/react'
25
+ import { createKoraHooks, KoraProvider } from '@korajs/react'
17
26
 
18
- function App() {
27
+ // Hooks typed by your schema
28
+ const { useCollection, useMutation, useQuery, useSyncStatus } = createKoraHooks<typeof app>()
29
+
30
+ export function Root() {
19
31
  return (
20
- <KoraProvider app={app}>
32
+ <KoraProvider app={app} fallback={<p>Loading...</p>}>
21
33
  <TodoList />
22
34
  </KoraProvider>
23
35
  )
24
36
  }
25
- ```
26
-
27
- ### Reactive Queries
28
-
29
- ```tsx
30
- import { useQuery } from '@korajs/react'
31
37
 
32
38
  function TodoList() {
33
- const todos = useQuery(app.todos.where({ completed: false }).orderBy('createdAt'))
34
- // Always up to date. No loading state needed for local data.
35
-
36
- return todos.map((todo) => <TodoItem key={todo.id} todo={todo} />)
37
- }
38
- ```
39
-
40
- ### Mutations
41
-
42
- ```tsx
43
- import { useMutation } from '@korajs/react'
44
-
45
- function AddTodo() {
46
- const addTodo = useMutation(app.todos.insert)
39
+ const todos = useCollection('todos')
40
+ const open = useQuery(todos.where({ completed: false }).orderBy('createdAt'))
41
+ const addTodo = useMutation(todos.insert)
42
+ const status = useSyncStatus()
47
43
 
48
- return <button onClick={() => addTodo({ title: 'New todo' })}>Add</button>
44
+ return (
45
+ <>
46
+ <button onClick={() => addTodo.mutate({ title: 'New todo' })}>Add</button>
47
+ {addTodo.error && <p role="alert">{addTodo.error.message}</p>}
48
+ <ul>
49
+ {open.map((todo) => (
50
+ <li key={todo.id}>{todo.title}</li>
51
+ ))}
52
+ </ul>
53
+ <small>
54
+ {status.status}, {status.pendingOperations} pending
55
+ </small>
56
+ </>
57
+ )
49
58
  }
50
59
  ```
51
60
 
52
- ### Sync Status
53
-
54
- ```tsx
55
- import { useSyncStatus } from '@korajs/react'
61
+ - `KoraProvider` renders `fallback` until the local database is open.
62
+ - `useQuery` renders `[]` first and the rows right after mount, then re-renders only when the
63
+ result changes. `useQueryState` returns `{ data, error, ready }`.
64
+ - `useMutation` returns `{ mutate, mutateAsync, isLoading, error, reset }`; writes land locally at
65
+ once and upload in the background.
66
+ - `useSyncStatus()` returns the full status (`status`, `pendingOperations`, `lastSyncedAt`, ...)
67
+ and re-renders only when it changes.
68
+ - `useRichText`, `usePresence`, `useCollaborators` and `AuthBoundKoraProvider` cover collaborative
69
+ editing and per-user databases.
56
70
 
57
- function SyncIndicator() {
58
- const status = useSyncStatus()
59
- // 'connected' | 'syncing' | 'synced' | 'offline' | 'error'
60
- return <span>{status}</span>
61
- }
62
- ```
71
+ ## Documentation
63
72
 
64
- All hooks use `useSyncExternalStore` for React 18+ concurrent mode safety.
73
+ [React Hooks guide](https://korajs.dev/guide/react-hooks) and the
74
+ [React API reference](https://korajs.dev/api/react).
65
75
 
66
76
  ## License
67
77
 
68
78
  MIT
69
-
70
- See the [full documentation](https://github.com/ehoneahobed/kora) for guides, API reference, and examples.
package/dist/index.cjs CHANGED
@@ -23,12 +23,14 @@ __export(index_exports, {
23
23
  AuthBoundKoraProvider: () => AuthBoundKoraProvider,
24
24
  KoraProvider: () => KoraProvider,
25
25
  classifyKoraInitializationError: () => classifyKoraInitializationError,
26
+ createKoraHooks: () => createKoraHooks,
26
27
  useApp: () => useApp,
27
28
  useCollaborators: () => useCollaborators,
28
29
  useCollection: () => useCollection,
29
30
  useMutation: () => useMutation,
30
31
  usePresence: () => usePresence,
31
32
  useQuery: () => useQuery,
33
+ useQueryState: () => useQueryState,
32
34
  useRichText: () => useRichText,
33
35
  useSyncStatus: () => useSyncStatus
34
36
  });
@@ -151,6 +153,7 @@ function AuthBoundKoraProvider({
151
153
  authClient,
152
154
  createApp,
153
155
  signedOut = null,
156
+ locked,
154
157
  fallback = null,
155
158
  error: renderError,
156
159
  children
@@ -244,6 +247,7 @@ function AuthBoundKoraProvider({
244
247
  );
245
248
  }
246
249
  if (state.state === "signed-out" || state.state === "anonymous") return signedOut;
250
+ if (state.state === "authenticated" && state.locked) return locked ?? signedOut;
247
251
  if (!app) return fallback;
248
252
  return (0, import_react2.createElement)(KoraProvider, { app, fallback }, children);
249
253
  }
@@ -267,10 +271,17 @@ var noopSubscribe = (_onStoreChange) => {
267
271
  return () => {
268
272
  };
269
273
  };
270
- function useQuery(query, options) {
274
+ var getServerSnapshot = () => EMPTY_ARRAY;
275
+ var getNoError = () => null;
276
+ function readQueryError(queryStore) {
277
+ if (!queryStore) return null;
278
+ const channel = queryStore;
279
+ return typeof channel.getError === "function" ? channel.getError() : null;
280
+ }
281
+ function useQuerySubscription(query, options) {
271
282
  const { queryStoreCache } = useKoraContext();
272
283
  const enabled = options?.enabled !== false;
273
- const descriptorKey = JSON.stringify(query.getDescriptor());
284
+ const descriptorKey = (0, import_store2.queryKey)(query.getDescriptor());
274
285
  const queryRef = (0, import_react3.useRef)(query);
275
286
  queryRef.current = query;
276
287
  const lastSnapshotRef = (0, import_react3.useRef)(EMPTY_ARRAY);
@@ -288,23 +299,42 @@ function useQuery(query, options) {
288
299
  queryStoreCache.release(currentQuery);
289
300
  };
290
301
  }, [descriptorKey, enabled, queryStoreCache]);
302
+ const subscribe = enabled && queryStore ? queryStore.subscribe : noopSubscribe;
291
303
  const getSnapshot = (0, import_react3.useCallback)(() => {
292
304
  if (!enabled) {
293
305
  lastSnapshotRef.current = EMPTY_ARRAY;
294
306
  return lastSnapshotRef.current;
295
307
  }
296
- if (!queryStore) {
297
- return lastSnapshotRef.current;
298
- }
299
- if (!queryStore.hasSnapshot()) {
308
+ if (!queryStore || !queryStore.hasSnapshot()) {
300
309
  return lastSnapshotRef.current;
301
310
  }
302
311
  const snapshot = queryStore.getSnapshot();
303
312
  lastSnapshotRef.current = snapshot;
304
313
  return snapshot;
305
314
  }, [enabled, queryStore]);
306
- return (0, import_react3.useSyncExternalStore)(queryStore ? queryStore.subscribe : noopSubscribe, getSnapshot);
315
+ const getError = (0, import_react3.useCallback)(
316
+ () => enabled ? readQueryError(queryStore) : null,
317
+ [enabled, queryStore]
318
+ );
319
+ const data = (0, import_react3.useSyncExternalStore)(subscribe, getSnapshot, getServerSnapshot);
320
+ const error = (0, import_react3.useSyncExternalStore)(subscribe, getError, getNoError);
321
+ const ready = enabled && queryStore !== null && queryStore.hasSnapshot();
322
+ return { data, error, ready };
307
323
  }
324
+ function useQuery(query, options) {
325
+ const { data, error } = useQuerySubscription(query, options);
326
+ if (error && options?.throwOnError !== false) {
327
+ throw error;
328
+ }
329
+ return data;
330
+ }
331
+ function useQueryState(query, options) {
332
+ const { data, error, ready } = useQuerySubscription(query, options);
333
+ return (0, import_react3.useMemo)(() => ({ data, error, ready }), [data, error, ready]);
334
+ }
335
+
336
+ // src/hooks/create-kora-hooks.ts
337
+ var import_react7 = require("react");
308
338
 
309
339
  // src/hooks/use-mutation.ts
310
340
  var import_bindings = require("@korajs/core/bindings");
@@ -312,98 +342,161 @@ var import_react5 = require("react");
312
342
 
313
343
  // src/hooks/use-controller.ts
314
344
  var import_react4 = require("react");
345
+ var ORPHAN_REAP_MS = 1e3;
315
346
  function useController(create, destroy, deps) {
316
- const controllerRef = (0, import_react4.useRef)(null);
347
+ const entryRef = (0, import_react4.useRef)(null);
317
348
  const createRef = (0, import_react4.useRef)(create);
318
349
  createRef.current = create;
319
- const [, forceRender] = (0, import_react4.useReducer)((count) => count + 1, 0);
320
- const getController = (0, import_react4.useRef)(() => {
321
- if (controllerRef.current === null) {
322
- controllerRef.current = createRef.current();
350
+ const destroyRef = (0, import_react4.useRef)(destroy);
351
+ destroyRef.current = destroy;
352
+ const [version, bumpVersion] = (0, import_react4.useReducer)((count) => count + 1, 0);
353
+ const handle = (0, import_react4.useRef)({
354
+ get: () => {
355
+ const current = entryRef.current;
356
+ if (current !== null && !current.reaped) {
357
+ return current.controller;
358
+ }
359
+ const entry = {
360
+ controller: createRef.current(),
361
+ claimed: false,
362
+ reaped: false
363
+ };
364
+ entryRef.current = entry;
365
+ setTimeout(() => {
366
+ if (!entry.claimed && !entry.reaped) {
367
+ entry.reaped = true;
368
+ destroyRef.current(entry.controller);
369
+ }
370
+ }, ORPHAN_REAP_MS);
371
+ return entry.controller;
372
+ },
373
+ peek: () => {
374
+ const current = entryRef.current;
375
+ return current?.claimed && !current.reaped ? current.controller : null;
323
376
  }
324
- return controllerRef.current;
325
377
  }).current;
326
378
  (0, import_react4.useEffect)(() => {
327
- if (controllerRef.current === null) {
328
- controllerRef.current = createRef.current();
329
- forceRender();
379
+ const current = entryRef.current;
380
+ if (current !== null && !current.reaped && !current.claimed) {
381
+ current.claimed = true;
382
+ } else if (current === null || current.reaped) {
383
+ entryRef.current = { controller: createRef.current(), claimed: true, reaped: false };
384
+ bumpVersion();
330
385
  }
331
386
  return () => {
332
- const controller = controllerRef.current;
333
- controllerRef.current = null;
334
- if (controller !== null) {
335
- destroy(controller);
387
+ const entry = entryRef.current;
388
+ entryRef.current = null;
389
+ if (entry !== null && !entry.reaped) {
390
+ entry.reaped = true;
391
+ destroyRef.current(entry.controller);
336
392
  }
337
393
  };
338
394
  }, deps);
339
- return getController;
395
+ return { get: handle.get, peek: handle.peek, version };
340
396
  }
341
397
 
342
398
  // src/hooks/use-mutation.ts
399
+ var IDLE = Object.freeze({ isLoading: false, error: null });
400
+ var getServerSnapshot2 = () => IDLE;
343
401
  function useMutation(mutationFn, options) {
344
402
  const fnRef = (0, import_react5.useRef)(mutationFn);
345
403
  fnRef.current = mutationFn;
346
404
  const optionsRef = (0, import_react5.useRef)(options);
347
405
  optionsRef.current = options;
348
- const getController = useController(
406
+ const controller = useController(
349
407
  () => (0, import_bindings.createMutationController)({
350
408
  mutationFn: (...args) => fnRef.current(...args),
351
409
  resolveOptions: () => optionsRef.current
352
410
  }),
353
- (controller) => controller.destroy(),
411
+ (instance) => instance.destroy(),
354
412
  []
355
413
  );
356
- const state = (0, import_react5.useSyncExternalStore)(
414
+ const getController = controller.get;
415
+ const subscribe = (0, import_react5.useCallback)(
357
416
  (onStoreChange) => getController().subscribe(onStoreChange),
358
- () => getController().getSnapshot(),
359
- () => getController().getSnapshot()
417
+ [getController, controller.version]
418
+ );
419
+ const getSnapshot = (0, import_react5.useCallback)(() => getController().getSnapshot(), [getController]);
420
+ const state = (0, import_react5.useSyncExternalStore)(subscribe, getSnapshot, getServerSnapshot2);
421
+ const mutate = (0, import_react5.useCallback)((...args) => getController().mutate(...args), [getController]);
422
+ const mutateAsync = (0, import_react5.useCallback)(
423
+ (...args) => getController().mutateAsync(...args),
424
+ [getController]
425
+ );
426
+ const reset = (0, import_react5.useCallback)(() => getController().reset(), [getController]);
427
+ return (0, import_react5.useMemo)(
428
+ () => ({ mutate, mutateAsync, isLoading: state.isLoading, error: state.error, reset }),
429
+ [mutate, mutateAsync, reset, state.isLoading, state.error]
360
430
  );
361
- return {
362
- mutate: (...args) => getController().mutate(...args),
363
- mutateAsync: (...args) => getController().mutateAsync(...args),
364
- isLoading: state.isLoading,
365
- error: state.error,
366
- reset: () => getController().reset()
367
- };
368
431
  }
369
432
 
370
433
  // src/hooks/use-sync-status.ts
371
434
  var import_sync = require("@korajs/sync");
372
435
  var import_react6 = require("react");
436
+ var getServerSnapshot3 = () => import_sync.OFFLINE_SYNC_STATUS;
373
437
  function useSyncStatus() {
374
438
  const { syncEngine, subscribeSyncStatus, events } = useKoraContext();
375
- const getController = useController(
439
+ const controller = useController(
376
440
  () => (0, import_sync.createSyncStatusController)({
377
441
  syncEngine,
378
442
  subscribeSyncStatus,
379
443
  events: subscribeSyncStatus ? null : events
380
444
  }),
381
- (controller) => controller.destroy(),
445
+ (instance) => instance.destroy(),
382
446
  [syncEngine, subscribeSyncStatus, events]
383
447
  );
384
- return (0, import_react6.useSyncExternalStore)(
448
+ const getController = controller.get;
449
+ const subscribe = (0, import_react6.useCallback)(
385
450
  (onStoreChange) => getController().subscribe(onStoreChange),
386
- () => getController().getSnapshot(),
387
- () => getController().getSnapshot()
451
+ [getController, controller.version]
452
+ );
453
+ const peek = controller.peek;
454
+ const getSnapshot = (0, import_react6.useCallback)(
455
+ () => peek()?.getSnapshot() ?? import_sync.OFFLINE_SYNC_STATUS,
456
+ [peek]
388
457
  );
458
+ return (0, import_react6.useSyncExternalStore)(subscribe, getSnapshot, getServerSnapshot3);
459
+ }
460
+
461
+ // src/hooks/create-kora-hooks.ts
462
+ function createKoraHooks() {
463
+ function useTypedApp() {
464
+ return useApp();
465
+ }
466
+ function useTypedCollection(name) {
467
+ const { app, store } = useKoraContext();
468
+ return (0, import_react7.useMemo)(() => {
469
+ const collections = app?.collections;
470
+ const accessor = collections && name in collections ? collections[name] : store.collection(name);
471
+ return accessor;
472
+ }, [app, store, name]);
473
+ }
474
+ return {
475
+ useApp: useTypedApp,
476
+ useCollection: useTypedCollection,
477
+ useQuery,
478
+ useQueryState,
479
+ useMutation,
480
+ useSyncStatus
481
+ };
389
482
  }
390
483
 
391
484
  // src/hooks/use-collection.ts
392
- var import_react7 = require("react");
485
+ var import_react8 = require("react");
393
486
  function useCollection(name) {
394
487
  const { store } = useKoraContext();
395
- return (0, import_react7.useMemo)(() => {
488
+ return (0, import_react8.useMemo)(() => {
396
489
  return store.collection(name);
397
490
  }, [store, name]);
398
491
  }
399
492
 
400
493
  // src/hooks/use-rich-text.ts
401
494
  var import_store3 = require("@korajs/store");
402
- var import_react8 = require("react");
495
+ var import_react9 = require("react");
403
496
  function useRichText(collectionName, recordId, fieldName, options) {
404
497
  const { store, syncEngine } = useKoraContext();
405
- const collection = (0, import_react8.useMemo)(() => store.collection(collectionName), [store, collectionName]);
406
- const getController = useController(
498
+ const collection = (0, import_react9.useMemo)(() => store.collection(collectionName), [store, collectionName]);
499
+ const controller = useController(
407
500
  () => (0, import_store3.createRichTextController)({
408
501
  collection,
409
502
  collectionName,
@@ -414,29 +507,35 @@ function useRichText(collectionName, recordId, fieldName, options) {
414
507
  useDocChannel: options?.useDocChannel,
415
508
  user: options?.user
416
509
  }),
417
- (controller) => controller.destroy(),
510
+ (instance) => instance.destroy(),
418
511
  [collection, collectionName, fieldName, options?.useDocChannel, recordId, store, syncEngine]
419
512
  );
420
- (0, import_react8.useEffect)(() => {
513
+ const getController = controller.get;
514
+ (0, import_react9.useEffect)(() => {
421
515
  getController().setUser(options?.user);
422
- }, [getController, options?.user]);
423
- const snapshot = (0, import_react8.useSyncExternalStore)(
516
+ }, [getController, options?.user, controller.version]);
517
+ const subscribe = (0, import_react9.useCallback)(
424
518
  (onStoreChange) => getController().subscribe(onStoreChange),
425
- () => getController().getSnapshot(),
426
- () => getController().getSnapshot()
519
+ [getController, controller.version]
427
520
  );
428
- const undo = (0, import_react8.useCallback)(() => {
521
+ const getSnapshot = (0, import_react9.useCallback)(() => getController().getSnapshot(), [getController]);
522
+ const snapshot = (0, import_react9.useSyncExternalStore)(subscribe, getSnapshot, getSnapshot);
523
+ const undo = (0, import_react9.useCallback)(() => {
429
524
  getController().undo();
430
525
  }, [getController]);
431
- const redo = (0, import_react8.useCallback)(() => {
526
+ const redo = (0, import_react9.useCallback)(() => {
432
527
  getController().redo();
433
528
  }, [getController]);
434
- const setCursor = (0, import_react8.useCallback)(
529
+ const setCursor = (0, import_react9.useCallback)(
435
530
  (anchor, head) => getController().setCursor(anchor, head),
436
531
  [getController]
437
532
  );
438
- const clearCursor = (0, import_react8.useCallback)(() => getController().clearCursor(), [getController]);
439
- return buildResult(getController(), snapshot, undo, redo, setCursor, clearCursor);
533
+ const clearCursor = (0, import_react9.useCallback)(() => getController().clearCursor(), [getController]);
534
+ const live = getController();
535
+ return (0, import_react9.useMemo)(
536
+ () => buildResult(live, snapshot, undo, redo, setCursor, clearCursor),
537
+ [live, snapshot, undo, redo, setCursor, clearCursor, controller.version]
538
+ );
440
539
  }
441
540
  function buildResult(controller, snapshot, undo, redo, setCursor, clearCursor) {
442
541
  return {
@@ -450,18 +549,21 @@ function buildResult(controller, snapshot, undo, redo, setCursor, clearCursor) {
450
549
  error: snapshot.error,
451
550
  cursors: [...snapshot.cursors],
452
551
  setCursor,
453
- clearCursor
552
+ clearCursor,
553
+ hasUnsavedChanges: snapshot.hasUnsavedChanges,
554
+ retrySave: () => controller.retrySave(),
555
+ getUnsavedState: () => controller.getUnsavedState()
454
556
  };
455
557
  }
456
558
 
457
559
  // src/hooks/use-presence.ts
458
- var import_react9 = require("react");
560
+ var import_react10 = require("react");
459
561
  function usePresence(user) {
460
562
  const { syncEngine } = useKoraContext();
461
563
  const name = user?.name ?? null;
462
564
  const color = user?.color ?? null;
463
565
  const avatar = user?.avatar ?? null;
464
- (0, import_react9.useEffect)(() => {
566
+ (0, import_react10.useEffect)(() => {
465
567
  if (!syncEngine || !name || !color) return;
466
568
  const awareness = syncEngine.getAwarenessManager();
467
569
  awareness.setLocalState({
@@ -475,12 +577,13 @@ function usePresence(user) {
475
577
 
476
578
  // src/hooks/use-collaborators.ts
477
579
  var import_sync2 = require("@korajs/sync");
478
- var import_react10 = require("react");
580
+ var import_react11 = require("react");
479
581
  var EMPTY_ARRAY2 = [];
582
+ var getServerSnapshot4 = () => EMPTY_ARRAY2;
480
583
  function useCollaborators() {
481
584
  const { syncEngine } = useKoraContext();
482
- const snapshotRef = (0, import_react10.useRef)(EMPTY_ARRAY2);
483
- const subscribe = (0, import_react10.useCallback)(
585
+ const snapshotRef = (0, import_react11.useRef)(EMPTY_ARRAY2);
586
+ const subscribe = (0, import_react11.useCallback)(
484
587
  (onStoreChange) => {
485
588
  if (!syncEngine) {
486
589
  snapshotRef.current = EMPTY_ARRAY2;
@@ -488,6 +591,7 @@ function useCollaborators() {
488
591
  };
489
592
  }
490
593
  const awareness = syncEngine.getAwarenessManager();
594
+ snapshotRef.current = EMPTY_ARRAY2;
491
595
  return (0, import_sync2.subscribeRemoteAwarenessStates)(awareness, (states) => {
492
596
  snapshotRef.current = states;
493
597
  onStoreChange();
@@ -495,25 +599,27 @@ function useCollaborators() {
495
599
  },
496
600
  [syncEngine]
497
601
  );
498
- const getSnapshot = (0, import_react10.useCallback)(() => snapshotRef.current, []);
499
- (0, import_react10.useEffect)(() => {
602
+ const getSnapshot = (0, import_react11.useCallback)(() => snapshotRef.current, []);
603
+ (0, import_react11.useEffect)(() => {
500
604
  if (!syncEngine) {
501
605
  snapshotRef.current = EMPTY_ARRAY2;
502
606
  }
503
607
  }, [syncEngine]);
504
- return (0, import_react10.useSyncExternalStore)(subscribe, getSnapshot);
608
+ return (0, import_react11.useSyncExternalStore)(subscribe, getSnapshot, getServerSnapshot4);
505
609
  }
506
610
  // Annotate the CommonJS export names for ESM import in node:
507
611
  0 && (module.exports = {
508
612
  AuthBoundKoraProvider,
509
613
  KoraProvider,
510
614
  classifyKoraInitializationError,
615
+ createKoraHooks,
511
616
  useApp,
512
617
  useCollaborators,
513
618
  useCollection,
514
619
  useMutation,
515
620
  usePresence,
516
621
  useQuery,
622
+ useQueryState,
517
623
  useRichText,
518
624
  useSyncStatus
519
625
  });