@syncular/client-react 0.0.4-25 → 0.0.6-100

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.
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Shared async initialization registry.
3
+ *
4
+ * Deduplicates concurrent (and subsequent) initialization calls by key.
5
+ * Failed initializations are evicted automatically so retries can run.
6
+ */
7
+
8
+ export interface AsyncInitRegistry<TKey, TValue> {
9
+ /**
10
+ * Run (or join) the initializer associated with a key.
11
+ * - First caller executes `init`.
12
+ * - Concurrent/future callers receive the same promise.
13
+ * - If `init` rejects, the key is evicted.
14
+ */
15
+ run(key: TKey, init: () => Promise<TValue> | TValue): Promise<TValue>;
16
+
17
+ /**
18
+ * Forget a single key so a subsequent `run` executes again.
19
+ */
20
+ invalidate(key: TKey): void;
21
+
22
+ /**
23
+ * Forget all cached entries.
24
+ */
25
+ clear(): void;
26
+ }
27
+
28
+ export function createAsyncInitRegistry<TKey, TValue>(): AsyncInitRegistry<
29
+ TKey,
30
+ TValue
31
+ > {
32
+ const cache = new Map<TKey, Promise<TValue>>();
33
+
34
+ function run(
35
+ key: TKey,
36
+ init: () => Promise<TValue> | TValue
37
+ ): Promise<TValue> {
38
+ const cached = cache.get(key);
39
+ if (cached) return cached;
40
+
41
+ const next = Promise.resolve().then(init);
42
+ cache.set(key, next);
43
+ void next.catch(() => {
44
+ if (cache.get(key) === next) {
45
+ cache.delete(key);
46
+ }
47
+ });
48
+ return next;
49
+ }
50
+
51
+ return {
52
+ run,
53
+ invalidate(key: TKey) {
54
+ cache.delete(key);
55
+ },
56
+ clear() {
57
+ cache.clear();
58
+ },
59
+ };
60
+ }
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import type {
12
- ClientTableRegistry,
12
+ ClientSyncConfig,
13
13
  MutationReceipt,
14
14
  MutationsApi,
15
15
  MutationsCommitFn,
@@ -28,7 +28,6 @@ import type {
28
28
  SyncRepairOptions,
29
29
  SyncResetOptions,
30
30
  SyncResetResult,
31
- SyncSubscriptionRequest,
32
31
  SyncTransport,
33
32
  TransportHealth,
34
33
  } from '@syncular/client';
@@ -99,16 +98,18 @@ export interface SyncContextValue<DB extends SyncClientDb> {
99
98
  engine: SyncEngine<DB>;
100
99
  db: Kysely<DB>;
101
100
  transport: SyncTransport;
102
- handlers: ClientTableRegistry<DB>;
101
+ handlers: ClientSyncConfig<DB, { actorId: string }>['handlers'];
103
102
  }
104
103
 
105
- export interface SyncProviderProps<DB extends SyncClientDb> {
104
+ export interface SyncProviderProps<
105
+ DB extends SyncClientDb,
106
+ Identity extends { actorId: string },
107
+ > {
106
108
  db: Kysely<DB>;
107
109
  transport: SyncTransport;
108
- handlers: ClientTableRegistry<DB>;
109
- actorId?: string | null;
110
+ sync: ClientSyncConfig<DB, Identity>;
111
+ identity: Identity;
110
112
  clientId?: string | null;
111
- subscriptions?: Array<Omit<SyncSubscriptionRequest, 'cursor'>>;
112
113
  limitCommits?: number;
113
114
  limitSnapshotRows?: number;
114
115
  maxSnapshotPages?: number;
@@ -418,16 +419,18 @@ export interface UsePresenceWithJoinResult<TMetadata = Record<string, unknown>>
418
419
  isJoined: boolean;
419
420
  }
420
421
 
421
- export function createSyncularReact<DB extends SyncClientDb>() {
422
+ export function createSyncularReact<
423
+ DB extends SyncClientDb,
424
+ Identity extends { actorId: string } = { actorId: string },
425
+ >() {
422
426
  const SyncContext = createContext<SyncContextValue<DB> | null>(null);
423
427
 
424
428
  function SyncProvider({
425
429
  db,
426
430
  transport,
427
- handlers,
428
- actorId,
431
+ sync,
432
+ identity,
429
433
  clientId,
430
- subscriptions = [],
431
434
  limitCommits,
432
435
  limitSnapshotRows,
433
436
  maxSnapshotPages,
@@ -446,15 +449,20 @@ export function createSyncularReact<DB extends SyncClientDb>() {
446
449
  autoStart = true,
447
450
  renderWhileStarting = true,
448
451
  children,
449
- }: SyncProviderProps<DB>): ReactNode {
452
+ }: SyncProviderProps<DB, Identity>): ReactNode {
453
+ const resolvedSubscriptions = useMemo(
454
+ () => sync.subscriptions(identity),
455
+ [sync, identity]
456
+ );
457
+
450
458
  const config = useMemo<SyncEngineConfig<DB>>(
451
459
  () => ({
452
460
  db,
453
461
  transport,
454
- handlers,
455
- actorId,
462
+ handlers: sync.handlers,
463
+ actorId: identity.actorId,
456
464
  clientId,
457
- subscriptions,
465
+ subscriptions: resolvedSubscriptions,
458
466
  limitCommits,
459
467
  limitSnapshotRows,
460
468
  maxSnapshotPages,
@@ -474,10 +482,10 @@ export function createSyncularReact<DB extends SyncClientDb>() {
474
482
  [
475
483
  db,
476
484
  transport,
477
- handlers,
478
- actorId,
485
+ sync,
486
+ identity,
479
487
  clientId,
480
- subscriptions,
488
+ resolvedSubscriptions,
481
489
  limitCommits,
482
490
  limitSnapshotRows,
483
491
  maxSnapshotPages,
@@ -499,27 +507,28 @@ export function createSyncularReact<DB extends SyncClientDb>() {
499
507
  const [engine] = useState(() => new SyncEngine(config));
500
508
 
501
509
  const [initialProps] = useState(() => ({
502
- actorId,
510
+ actorId: identity.actorId,
503
511
  clientId,
504
512
  db,
505
513
  transport,
506
- handlers,
514
+ sync,
507
515
  }));
508
516
 
509
517
  useEffect(() => {
510
518
  const changedProps: string[] = [];
511
- if (actorId !== initialProps.actorId) changedProps.push('actorId');
519
+ if (identity.actorId !== initialProps.actorId)
520
+ changedProps.push('actorId');
512
521
  if (clientId !== initialProps.clientId) changedProps.push('clientId');
513
522
  if (db !== initialProps.db) changedProps.push('db');
514
523
  if (transport !== initialProps.transport) changedProps.push('transport');
515
- if (handlers !== initialProps.handlers) changedProps.push('handlers');
524
+ if (sync !== initialProps.sync) changedProps.push('sync');
516
525
 
517
526
  if (changedProps.length > 0) {
518
527
  const message =
519
528
  `[SyncProvider] Critical props changed after mount: ${changedProps.join(', ')}. ` +
520
529
  'This is not supported and may cause undefined behavior. ' +
521
530
  'Use a React key prop to force remount, e.g., ' +
522
- `<SyncProvider key={userId} ...> or <SyncProvider key={actorId + ':' + clientId} ...>`;
531
+ `<SyncProvider key={userId} ...> or <SyncProvider key={identity.actorId + ':' + clientId} ...>`;
523
532
 
524
533
  console.error(message);
525
534
  if (process.env.NODE_ENV === 'development') {
@@ -529,7 +538,7 @@ export function createSyncularReact<DB extends SyncClientDb>() {
529
538
  );
530
539
  }
531
540
  }
532
- }, [actorId, clientId, db, transport, handlers, initialProps]);
541
+ }, [identity, clientId, db, transport, sync, initialProps]);
533
542
 
534
543
  const [isReady, setIsReady] = useState(false);
535
544
 
@@ -561,19 +570,19 @@ export function createSyncularReact<DB extends SyncClientDb>() {
561
570
  }, [engine, autoStart]);
562
571
 
563
572
  useEffect(() => {
564
- if (isReady && subscriptions.length > 0) {
565
- engine.updateSubscriptions(subscriptions);
573
+ if (isReady && resolvedSubscriptions.length > 0) {
574
+ engine.updateSubscriptions(resolvedSubscriptions);
566
575
  }
567
- }, [engine, isReady, subscriptions]);
576
+ }, [engine, isReady, resolvedSubscriptions]);
568
577
 
569
578
  const value = useMemo<SyncContextValue<DB>>(
570
579
  () => ({
571
580
  engine,
572
581
  db,
573
582
  transport,
574
- handlers,
583
+ handlers: sync.handlers,
575
584
  }),
576
- [engine, db, transport, handlers]
585
+ [engine, db, transport, sync]
577
586
  );
578
587
 
579
588
  if (!isReady && renderWhileStarting === false) {
package/src/index.ts CHANGED
@@ -5,6 +5,8 @@
5
5
  * typed to your application's DB schema.
6
6
  */
7
7
 
8
+ export type { AsyncInitRegistry } from './async-init-registry';
9
+ export { createAsyncInitRegistry } from './async-init-registry';
8
10
  export type {
9
11
  ConflictResolution,
10
12
  FluentMutation,
@@ -43,6 +45,12 @@ export type {
43
45
  } from './createSyncularReact';
44
46
  // Re-export core client types for convenience.
45
47
  export { createSyncularReact } from './createSyncularReact';
48
+ export type { UseCachedAsyncValueOptions } from './use-cached-async-value';
49
+ export {
50
+ clearCachedAsyncValues,
51
+ invalidateCachedAsyncValue,
52
+ useCachedAsyncValue,
53
+ } from './use-cached-async-value';
46
54
  export type {
47
55
  SyncGroupChannel,
48
56
  SyncGroupChannelSnapshot,
@@ -0,0 +1,68 @@
1
+ import { useEffect, useRef, useState } from 'react';
2
+ import { createAsyncInitRegistry } from './async-init-registry';
3
+
4
+ const cachedAsyncValueRegistry = createAsyncInitRegistry<unknown, unknown>();
5
+
6
+ const EMPTY_DEPS: readonly unknown[] = Object.freeze([]);
7
+ const DEFAULT_KEY = 'default';
8
+
9
+ export interface UseCachedAsyncValueOptions<TKey = unknown> {
10
+ /**
11
+ * Stable cache key shared across component instances.
12
+ * Defaults to the callback function identity.
13
+ */
14
+ key?: TKey;
15
+ /**
16
+ * Additional dependencies that should re-run the callback.
17
+ */
18
+ deps?: readonly unknown[];
19
+ }
20
+
21
+ /**
22
+ * Resolve an async value with a process-wide cache outside component state.
23
+ * Returns the resolved value and error tuple: [value, error].
24
+ */
25
+ export function useCachedAsyncValue<TValue>(
26
+ run: () => Promise<TValue> | TValue,
27
+ options?: UseCachedAsyncValueOptions
28
+ ): readonly [TValue | null, Error | null] {
29
+ const [value, setValue] = useState<TValue | null>(null);
30
+ const [error, setError] = useState<Error | null>(null);
31
+
32
+ const runRef = useRef(run);
33
+ runRef.current = run;
34
+
35
+ const key = options?.key ?? DEFAULT_KEY;
36
+ const deps = options?.deps ?? EMPTY_DEPS;
37
+
38
+ useEffect(() => {
39
+ let cancelled = false;
40
+ setValue(null);
41
+ setError(null);
42
+
43
+ void cachedAsyncValueRegistry
44
+ .run(key, () => runRef.current())
45
+ .then((resolved) => {
46
+ if (cancelled) return;
47
+ setValue(resolved as TValue);
48
+ })
49
+ .catch((cause: unknown) => {
50
+ if (cancelled) return;
51
+ setError(cause instanceof Error ? cause : new Error(String(cause)));
52
+ });
53
+
54
+ return () => {
55
+ cancelled = true;
56
+ };
57
+ }, [key, ...deps]);
58
+
59
+ return [value, error] as const;
60
+ }
61
+
62
+ export function invalidateCachedAsyncValue(key: unknown): void {
63
+ cachedAsyncValueRegistry.invalidate(key);
64
+ }
65
+
66
+ export function clearCachedAsyncValues(): void {
67
+ cachedAsyncValueRegistry.clear();
68
+ }