@korajs/react 0.4.0 → 0.6.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
@@ -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,17 +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
- };
54
- return createElement(KoraContext.Provider, { value }, children);
74
+ return createElement(KoraContext.Provider, { value: contextValue }, children);
55
75
  }
56
76
  function useKoraContext() {
57
77
  const context = useContext(KoraContext);
@@ -75,111 +95,35 @@ function useApp() {
75
95
  }
76
96
 
77
97
  // src/hooks/use-query.ts
78
- import { useMemo, useRef, useSyncExternalStore } from "react";
79
-
80
- // 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";
81
100
  var EMPTY_ARRAY = Object.freeze([]);
82
- var QueryStore = class {
83
- snapshot = EMPTY_ARRAY;
84
- listeners = /* @__PURE__ */ new Set();
85
- unsubscribeQuery = null;
86
- active = false;
87
- queryBuilder;
88
- constructor(queryBuilder) {
89
- this.queryBuilder = queryBuilder;
90
- }
91
- /**
92
- * Subscribe to snapshot changes. Compatible with useSyncExternalStore.
93
- *
94
- * Lazily starts the underlying query subscription when the first listener
95
- * attaches, and stops it when the last listener detaches. This allows
96
- * React StrictMode to unmount/remount without permanently killing the subscription.
97
- *
98
- * @returns Unsubscribe function
99
- */
100
- subscribe = (onStoreChange) => {
101
- this.listeners.add(onStoreChange);
102
- if (!this.active) {
103
- this.startSubscription();
104
- }
105
- return () => {
106
- this.listeners.delete(onStoreChange);
107
- if (this.listeners.size === 0) {
108
- this.stopSubscription();
109
- }
110
- };
111
- };
112
- /**
113
- * Get the current snapshot synchronously. Compatible with useSyncExternalStore.
114
- * Returns EMPTY_ARRAY before the first async fetch completes.
115
- */
116
- getSnapshot = () => {
117
- return this.snapshot;
118
- };
119
- /**
120
- * Clean up the underlying subscription and release resources.
121
- * Called by useMemo when the query descriptor changes.
122
- */
123
- destroy() {
124
- this.stopSubscription();
125
- this.listeners.clear();
126
- this.snapshot = EMPTY_ARRAY;
127
- }
128
- startSubscription() {
129
- this.active = true;
130
- this.unsubscribeQuery = this.queryBuilder.subscribe((results) => {
131
- if (!this.active) return;
132
- const newSnapshot = Object.freeze([...results]);
133
- this.snapshot = newSnapshot;
134
- this.notifyListeners();
135
- });
136
- }
137
- stopSubscription() {
138
- this.active = false;
139
- if (this.unsubscribeQuery) {
140
- this.unsubscribeQuery();
141
- this.unsubscribeQuery = null;
142
- }
143
- }
144
- notifyListeners() {
145
- for (const listener of this.listeners) {
146
- listener();
147
- }
148
- }
149
- };
150
-
151
- // src/hooks/use-query.ts
152
- var EMPTY_ARRAY2 = Object.freeze([]);
153
101
  var noopSubscribe = (_onStoreChange) => {
154
102
  return () => {
155
103
  };
156
104
  };
157
105
  function useQuery(query, options) {
106
+ const { queryStoreCache } = useKoraContext();
158
107
  const enabled = options?.enabled !== false;
159
108
  const descriptorKey = JSON.stringify(query.getDescriptor());
160
- const queryStoreRef = useRef(null);
161
- const prevKeyRef = useRef(null);
162
- const queryStore = useMemo(() => {
109
+ const queryRef = useRef2(query);
110
+ queryRef.current = query;
111
+ const [queryStore, setQueryStore] = useState2(null);
112
+ useEffect2(() => {
163
113
  if (!enabled) {
164
- if (queryStoreRef.current) {
165
- queryStoreRef.current.destroy();
166
- queryStoreRef.current = null;
167
- }
168
- prevKeyRef.current = null;
169
- return null;
114
+ setQueryStore(null);
115
+ return;
170
116
  }
171
- if (prevKeyRef.current !== descriptorKey) {
172
- if (queryStoreRef.current) {
173
- queryStoreRef.current.destroy();
174
- }
175
- const newStore = new QueryStore(query);
176
- queryStoreRef.current = newStore;
177
- prevKeyRef.current = descriptorKey;
178
- return newStore;
179
- }
180
- return queryStoreRef.current;
181
- }, [descriptorKey, enabled, query]);
182
- 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;
183
127
  return useSyncExternalStore(
184
128
  queryStore ? queryStore.subscribe : noopSubscribe,
185
129
  queryStore ? queryStore.getSnapshot : disabledGetSnapshot
@@ -187,281 +131,177 @@ function useQuery(query, options) {
187
131
  }
188
132
 
189
133
  // src/hooks/use-mutation.ts
190
- import { useCallback, useEffect as useEffect2, useRef as useRef2, useState as useState2 } from "react";
191
- function useMutation(mutationFn) {
192
- const [state, setState] = useState2({
193
- isLoading: false,
194
- error: null
195
- });
196
- const mountedRef = useRef2(true);
197
- useEffect2(() => {
198
- mountedRef.current = true;
199
- return () => {
200
- mountedRef.current = false;
201
- };
202
- }, []);
203
- const fnRef = useRef2(mutationFn);
134
+ import { createMutationController } from "@korajs/core/bindings";
135
+ import { useEffect as useEffect3, useMemo as useMemo2, useRef as useRef3, useSyncExternalStore as useSyncExternalStore2 } from "react";
136
+ function useMutation(mutationFn, options) {
137
+ const fnRef = useRef3(mutationFn);
204
138
  fnRef.current = mutationFn;
205
- const mutateAsync = useCallback(async (...args) => {
206
- if (mountedRef.current) {
207
- setState({ isLoading: true, error: null });
208
- }
209
- try {
210
- const result = await fnRef.current(...args);
211
- if (mountedRef.current) {
212
- setState({ isLoading: false, error: null });
213
- }
214
- return result;
215
- } catch (err) {
216
- const error = err instanceof Error ? err : new Error(String(err));
217
- if (mountedRef.current) {
218
- setState({ isLoading: false, error });
219
- }
220
- throw error;
221
- }
222
- }, []);
223
- const mutate = useCallback(
224
- (...args) => {
225
- mutateAsync(...args).catch(() => {
226
- });
227
- },
228
- [mutateAsync]
139
+ const optionsRef = useRef3(options);
140
+ optionsRef.current = options;
141
+ const controller = useMemo2(
142
+ () => createMutationController({
143
+ mutationFn: (...args) => fnRef.current(...args),
144
+ resolveOptions: () => optionsRef.current
145
+ }),
146
+ []
147
+ );
148
+ useEffect3(() => () => controller.destroy(), [controller]);
149
+ const state = useSyncExternalStore2(
150
+ (onStoreChange) => controller.subscribe(onStoreChange),
151
+ () => controller.getSnapshot(),
152
+ () => controller.getSnapshot()
229
153
  );
230
- const reset = useCallback(() => {
231
- if (mountedRef.current) {
232
- setState({ isLoading: false, error: null });
233
- }
234
- }, []);
235
154
  return {
236
- mutate,
237
- mutateAsync,
155
+ mutate: (...args) => controller.mutate(...args),
156
+ mutateAsync: (...args) => controller.mutateAsync(...args),
238
157
  isLoading: state.isLoading,
239
158
  error: state.error,
240
- reset
159
+ reset: () => controller.reset()
241
160
  };
242
161
  }
243
162
 
244
163
  // src/hooks/use-sync-status.ts
245
- import { useCallback as useCallback2, useEffect as useEffect3, useRef as useRef3, useSyncExternalStore as useSyncExternalStore2 } from "react";
246
- var POLL_INTERVAL_MS = 500;
247
- var OFFLINE_STATUS = Object.freeze({
248
- status: "offline",
249
- pendingOperations: 0,
250
- lastSyncedAt: null,
251
- lastSuccessfulPush: null,
252
- lastSuccessfulPull: null,
253
- conflicts: 0
254
- });
164
+ import { createSyncStatusController } from "@korajs/sync";
165
+ import { useEffect as useEffect4, useMemo as useMemo3, useSyncExternalStore as useSyncExternalStore3 } from "react";
255
166
  function useSyncStatus() {
256
- const { syncEngine } = useKoraContext();
257
- const snapshotRef = useRef3(OFFLINE_STATUS);
258
- const serializedRef = useRef3(JSON.stringify(OFFLINE_STATUS));
259
- const subscribe = useCallback2(
260
- (onStoreChange) => {
261
- if (!syncEngine) return () => {
262
- };
263
- const intervalId = setInterval(() => {
264
- const newStatus = syncEngine.getStatus();
265
- const newSerialized = JSON.stringify(newStatus);
266
- if (newSerialized !== serializedRef.current) {
267
- snapshotRef.current = newStatus;
268
- serializedRef.current = newSerialized;
269
- onStoreChange();
270
- }
271
- }, POLL_INTERVAL_MS);
272
- const initialStatus = syncEngine.getStatus();
273
- const initialSerialized = JSON.stringify(initialStatus);
274
- if (initialSerialized !== serializedRef.current) {
275
- snapshotRef.current = initialStatus;
276
- serializedRef.current = initialSerialized;
277
- onStoreChange();
278
- }
279
- return () => {
280
- clearInterval(intervalId);
281
- };
282
- },
283
- [syncEngine]
167
+ const { syncEngine, subscribeSyncStatus, events } = useKoraContext();
168
+ const controller = useMemo3(
169
+ () => createSyncStatusController({
170
+ syncEngine,
171
+ subscribeSyncStatus,
172
+ events: subscribeSyncStatus ? null : events
173
+ }),
174
+ [syncEngine, subscribeSyncStatus, events]
175
+ );
176
+ useEffect4(() => () => controller.destroy(), [controller]);
177
+ return useSyncExternalStore3(
178
+ (onStoreChange) => controller.subscribe(onStoreChange),
179
+ () => controller.getSnapshot(),
180
+ () => controller.getSnapshot()
284
181
  );
285
- const getSnapshot = useCallback2(() => {
286
- return snapshotRef.current;
287
- }, []);
288
- useEffect3(() => {
289
- if (!syncEngine) {
290
- snapshotRef.current = OFFLINE_STATUS;
291
- serializedRef.current = JSON.stringify(OFFLINE_STATUS);
292
- }
293
- }, [syncEngine]);
294
- return useSyncExternalStore2(subscribe, getSnapshot);
295
182
  }
296
183
 
297
184
  // src/hooks/use-collection.ts
298
- import { useMemo as useMemo2 } from "react";
185
+ import { useMemo as useMemo4 } from "react";
299
186
  function useCollection(name) {
300
187
  const { store } = useKoraContext();
301
- return useMemo2(() => {
188
+ return useMemo4(() => {
302
189
  return store.collection(name);
303
190
  }, [store, name]);
304
191
  }
305
192
 
306
193
  // src/hooks/use-rich-text.ts
307
- import { useCallback as useCallback3, useEffect as useEffect4, useMemo as useMemo3, useRef as useRef4, useState as useState3 } from "react";
308
- import * as Y from "yjs";
309
- var LOAD_ORIGIN = "kora-load";
310
- var TEXT_KEY = "content";
311
- var COMPACT_AFTER_DELTAS = 20;
312
- function useRichText(collectionName, recordId, fieldName) {
194
+ import { asRichTextSyncEngine, createRichTextController } from "@korajs/store";
195
+ import { useCallback, useEffect as useEffect5, useMemo as useMemo5, useSyncExternalStore as useSyncExternalStore4 } from "react";
196
+ function useRichText(collectionName, recordId, fieldName, options) {
313
197
  const { store, syncEngine } = useKoraContext();
314
- const collection = useMemo3(
198
+ const collection = useMemo5(
315
199
  () => store.collection(collectionName),
316
200
  [store, collectionName]
317
201
  );
318
- const [doc] = useState3(() => new Y.Doc());
319
- const [ready, setReady] = useState3(false);
320
- const [error, setError] = useState3(null);
321
- const [canUndo, setCanUndo] = useState3(false);
322
- const [canRedo, setCanRedo] = useState3(false);
323
- const [cursors, setCursors] = useState3([]);
324
- const baseUpdateRef = useRef4(null);
325
- const pendingDeltasRef = useRef4([]);
326
- const text = useMemo3(() => doc.getText(TEXT_KEY), [doc]);
327
- const undoManager = useMemo3(() => new Y.UndoManager(text), [text]);
328
- const syncHistoryState = useCallback3(() => {
329
- setCanUndo(undoManager.undoStack.length > 0);
330
- setCanRedo(undoManager.redoStack.length > 0);
331
- }, [undoManager]);
332
- const undo = useCallback3(() => {
333
- undoManager.undo();
334
- syncHistoryState();
335
- }, [syncHistoryState, undoManager]);
336
- const redo = useCallback3(() => {
337
- undoManager.redo();
338
- syncHistoryState();
339
- }, [syncHistoryState, undoManager]);
340
- useEffect4(() => {
341
- let disposed = false;
342
- const initialize = async () => {
343
- setReady(false);
344
- setError(null);
345
- try {
346
- const record = await collection.findById(recordId);
347
- if (disposed) return;
348
- doc.transact(() => {
349
- const target = doc.getText(TEXT_KEY);
350
- target.delete(0, target.length);
351
- }, LOAD_ORIGIN);
352
- const encoded = encodeRichtextInput(record?.[fieldName]);
353
- baseUpdateRef.current = encoded;
354
- pendingDeltasRef.current = [];
355
- if (encoded) {
356
- Y.applyUpdate(doc, encoded, LOAD_ORIGIN);
357
- }
358
- setReady(true);
359
- } catch (cause) {
360
- if (disposed) return;
361
- setError(cause instanceof Error ? cause : new Error(String(cause)));
362
- }
363
- };
364
- const persist = async (_update, origin) => {
365
- syncHistoryState();
366
- if (origin === LOAD_ORIGIN) {
367
- return;
368
- }
369
- pendingDeltasRef.current.push(_update);
370
- const snapshot = composeRichtextSnapshot(baseUpdateRef.current, pendingDeltasRef.current);
371
- if (pendingDeltasRef.current.length >= COMPACT_AFTER_DELTAS) {
372
- baseUpdateRef.current = snapshot;
373
- pendingDeltasRef.current = [];
374
- }
375
- try {
376
- await collection.update(recordId, {
377
- [fieldName]: snapshot
378
- });
379
- } catch (cause) {
380
- if (!disposed) {
381
- setError(cause instanceof Error ? cause : new Error(String(cause)));
382
- }
383
- }
384
- };
385
- doc.on("update", persist);
386
- void initialize();
387
- syncHistoryState();
388
- return () => {
389
- disposed = true;
390
- doc.off("update", persist);
391
- undoManager.destroy();
392
- baseUpdateRef.current = null;
393
- pendingDeltasRef.current = [];
394
- };
395
- }, [collection, doc, fieldName, recordId, syncHistoryState, undoManager]);
396
- useEffect4(() => {
397
- if (!syncEngine) return;
202
+ const controller = useMemo5(
203
+ () => createRichTextController({
204
+ collection,
205
+ collectionName,
206
+ recordId,
207
+ fieldName,
208
+ store,
209
+ syncEngine: asRichTextSyncEngine(syncEngine),
210
+ useDocChannel: options?.useDocChannel,
211
+ user: options?.user
212
+ }),
213
+ [collection, collectionName, fieldName, options?.useDocChannel, recordId, store, syncEngine]
214
+ );
215
+ useEffect5(() => {
216
+ controller.setUser(options?.user);
217
+ }, [controller, options?.user]);
218
+ useEffect5(() => () => controller.destroy(), [controller]);
219
+ const snapshot = useSyncExternalStore4(
220
+ (onStoreChange) => controller.subscribe(onStoreChange),
221
+ () => controller.getSnapshot(),
222
+ () => controller.getSnapshot()
223
+ );
224
+ const undo = useCallback(() => controller.undo(), [controller]);
225
+ const redo = useCallback(() => controller.redo(), [controller]);
226
+ const setCursor = useCallback(
227
+ (anchor, head) => controller.setCursor(anchor, head),
228
+ [controller]
229
+ );
230
+ const clearCursor = useCallback(() => controller.clearCursor(), [controller]);
231
+ return buildResult(controller, snapshot, undo, redo, setCursor, clearCursor);
232
+ }
233
+ function buildResult(controller, snapshot, undo, redo, setCursor, clearCursor) {
234
+ return {
235
+ doc: controller.doc,
236
+ text: controller.text,
237
+ undo,
238
+ redo,
239
+ canUndo: snapshot.canUndo,
240
+ canRedo: snapshot.canRedo,
241
+ ready: snapshot.ready,
242
+ error: snapshot.error,
243
+ cursors: [...snapshot.cursors],
244
+ setCursor,
245
+ clearCursor
246
+ };
247
+ }
248
+
249
+ // src/hooks/use-presence.ts
250
+ import { useEffect as useEffect6 } from "react";
251
+ function usePresence(user) {
252
+ const { syncEngine } = useKoraContext();
253
+ const name = user?.name ?? null;
254
+ const color = user?.color ?? null;
255
+ const avatar = user?.avatar ?? null;
256
+ useEffect6(() => {
257
+ if (!syncEngine || !name || !color) return;
398
258
  const awareness = syncEngine.getAwarenessManager();
399
- const localClientId = awareness.clientId;
400
- const updateCursors = () => {
401
- const states = awareness.getStates();
402
- const fieldCursors = [];
403
- for (const [clientId, state] of states) {
404
- if (clientId === localClientId) continue;
405
- if (!state.cursor) continue;
406
- if (state.cursor.collection !== collectionName || state.cursor.recordId !== recordId || state.cursor.field !== fieldName) {
407
- continue;
408
- }
409
- fieldCursors.push({
410
- clientId,
411
- userName: state.user.name,
412
- color: state.user.color,
413
- anchor: state.cursor.anchor,
414
- head: state.cursor.head
415
- });
416
- }
417
- setCursors(fieldCursors);
418
- };
419
- const unsubscribe = awareness.on("change", () => {
420
- updateCursors();
259
+ awareness.setLocalState({
260
+ user: { name, color, avatar: avatar ?? void 0 }
421
261
  });
422
- updateCursors();
423
- return unsubscribe;
424
- }, [syncEngine, collectionName, recordId, fieldName]);
425
- return { doc, text, undo, redo, canUndo, canRedo, ready, error, cursors };
262
+ return () => {
263
+ awareness.setLocalState(null);
264
+ };
265
+ }, [syncEngine, name, color, avatar]);
426
266
  }
427
- function encodeRichtextInput(value) {
428
- if (value === null || value === void 0) {
429
- return null;
430
- }
431
- if (typeof value === "string") {
432
- const doc = new Y.Doc();
433
- doc.getText(TEXT_KEY).insert(0, value);
434
- return Y.encodeStateAsUpdate(doc);
435
- }
436
- if (value instanceof Uint8Array) {
437
- return value;
438
- }
439
- if (value instanceof ArrayBuffer) {
440
- return new Uint8Array(value);
441
- }
442
- if (typeof globalThis !== "undefined" && "Buffer" in globalThis) {
443
- const BufferCtor = globalThis.Buffer;
444
- if (BufferCtor.isBuffer(value)) {
445
- return new Uint8Array(value.buffer, value.byteOffset, value.byteLength);
267
+
268
+ // src/hooks/use-collaborators.ts
269
+ import { subscribeRemoteAwarenessStates } from "@korajs/sync";
270
+ import { useCallback as useCallback2, useEffect as useEffect7, useRef as useRef4, useSyncExternalStore as useSyncExternalStore5 } from "react";
271
+ var EMPTY_ARRAY2 = [];
272
+ function useCollaborators() {
273
+ const { syncEngine } = useKoraContext();
274
+ const snapshotRef = useRef4(EMPTY_ARRAY2);
275
+ const subscribe = useCallback2(
276
+ (onStoreChange) => {
277
+ if (!syncEngine) {
278
+ snapshotRef.current = EMPTY_ARRAY2;
279
+ return () => {
280
+ };
281
+ }
282
+ const awareness = syncEngine.getAwarenessManager();
283
+ return subscribeRemoteAwarenessStates(awareness, (states) => {
284
+ snapshotRef.current = states;
285
+ onStoreChange();
286
+ });
287
+ },
288
+ [syncEngine]
289
+ );
290
+ const getSnapshot = useCallback2(() => snapshotRef.current, []);
291
+ useEffect7(() => {
292
+ if (!syncEngine) {
293
+ snapshotRef.current = EMPTY_ARRAY2;
446
294
  }
447
- }
448
- throw new Error("Richtext record value must be a string, Uint8Array, ArrayBuffer, or null.");
449
- }
450
- function composeRichtextSnapshot(base, deltas) {
451
- const doc = new Y.Doc();
452
- if (base) {
453
- Y.applyUpdate(doc, base);
454
- }
455
- for (const delta of deltas) {
456
- Y.applyUpdate(doc, delta);
457
- }
458
- return Y.encodeStateAsUpdate(doc);
295
+ }, [syncEngine]);
296
+ return useSyncExternalStore5(subscribe, getSnapshot);
459
297
  }
460
298
  export {
461
299
  KoraProvider,
462
300
  useApp,
301
+ useCollaborators,
463
302
  useCollection,
464
303
  useMutation,
304
+ usePresence,
465
305
  useQuery,
466
306
  useRichText,
467
307
  useSyncStatus