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

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 {
@@ -455,13 +554,13 @@ function buildResult(controller, snapshot, undo, redo, setCursor, clearCursor) {
455
554
  }
456
555
 
457
556
  // src/hooks/use-presence.ts
458
- var import_react9 = require("react");
557
+ var import_react10 = require("react");
459
558
  function usePresence(user) {
460
559
  const { syncEngine } = useKoraContext();
461
560
  const name = user?.name ?? null;
462
561
  const color = user?.color ?? null;
463
562
  const avatar = user?.avatar ?? null;
464
- (0, import_react9.useEffect)(() => {
563
+ (0, import_react10.useEffect)(() => {
465
564
  if (!syncEngine || !name || !color) return;
466
565
  const awareness = syncEngine.getAwarenessManager();
467
566
  awareness.setLocalState({
@@ -475,12 +574,13 @@ function usePresence(user) {
475
574
 
476
575
  // src/hooks/use-collaborators.ts
477
576
  var import_sync2 = require("@korajs/sync");
478
- var import_react10 = require("react");
577
+ var import_react11 = require("react");
479
578
  var EMPTY_ARRAY2 = [];
579
+ var getServerSnapshot4 = () => EMPTY_ARRAY2;
480
580
  function useCollaborators() {
481
581
  const { syncEngine } = useKoraContext();
482
- const snapshotRef = (0, import_react10.useRef)(EMPTY_ARRAY2);
483
- const subscribe = (0, import_react10.useCallback)(
582
+ const snapshotRef = (0, import_react11.useRef)(EMPTY_ARRAY2);
583
+ const subscribe = (0, import_react11.useCallback)(
484
584
  (onStoreChange) => {
485
585
  if (!syncEngine) {
486
586
  snapshotRef.current = EMPTY_ARRAY2;
@@ -488,6 +588,7 @@ function useCollaborators() {
488
588
  };
489
589
  }
490
590
  const awareness = syncEngine.getAwarenessManager();
591
+ snapshotRef.current = EMPTY_ARRAY2;
491
592
  return (0, import_sync2.subscribeRemoteAwarenessStates)(awareness, (states) => {
492
593
  snapshotRef.current = states;
493
594
  onStoreChange();
@@ -495,25 +596,27 @@ function useCollaborators() {
495
596
  },
496
597
  [syncEngine]
497
598
  );
498
- const getSnapshot = (0, import_react10.useCallback)(() => snapshotRef.current, []);
499
- (0, import_react10.useEffect)(() => {
599
+ const getSnapshot = (0, import_react11.useCallback)(() => snapshotRef.current, []);
600
+ (0, import_react11.useEffect)(() => {
500
601
  if (!syncEngine) {
501
602
  snapshotRef.current = EMPTY_ARRAY2;
502
603
  }
503
604
  }, [syncEngine]);
504
- return (0, import_react10.useSyncExternalStore)(subscribe, getSnapshot);
605
+ return (0, import_react11.useSyncExternalStore)(subscribe, getSnapshot, getServerSnapshot4);
505
606
  }
506
607
  // Annotate the CommonJS export names for ESM import in node:
507
608
  0 && (module.exports = {
508
609
  AuthBoundKoraProvider,
509
610
  KoraProvider,
510
611
  classifyKoraInitializationError,
612
+ createKoraHooks,
511
613
  useApp,
512
614
  useCollaborators,
513
615
  useCollection,
514
616
  useMutation,
515
617
  usePresence,
516
618
  useQuery,
619
+ useQueryState,
517
620
  useRichText,
518
621
  useSyncStatus
519
622
  });