@syncular/client-react 0.0.4-26 → 0.0.6-101
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/async-init-registry.d.ts +25 -0
- package/dist/async-init-registry.d.ts.map +1 -0
- package/dist/async-init-registry.js +32 -0
- package/dist/async-init-registry.js.map +1 -0
- package/dist/createSyncularReact.d.ts +15 -8
- package/dist/createSyncularReact.d.ts.map +1 -1
- package/dist/createSyncularReact.js +20 -19
- package/dist/createSyncularReact.js.map +1 -1
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/use-cached-async-value.d.ts +19 -0
- package/dist/use-cached-async-value.d.ts.map +1 -0
- package/dist/use-cached-async-value.js +45 -0
- package/dist/use-cached-async-value.js.map +1 -0
- package/package.json +8 -8
- package/src/__tests__/SyncEngine.test.ts +43 -39
- package/src/__tests__/SyncProvider.strictmode.test.tsx +4 -3
- package/src/__tests__/async-init-registry.test.ts +64 -0
- package/src/__tests__/hooks/useMutation.test.tsx +4 -3
- package/src/__tests__/hooks.test.tsx +8 -7
- package/src/__tests__/integration/provider-reconfig.test.ts +38 -21
- package/src/__tests__/integration/push-flow.test.ts +3 -3
- package/src/__tests__/test-utils.ts +30 -11
- package/src/__tests__/use-cached-async-value.test.tsx +111 -0
- package/src/__tests__/useMutations.test.tsx +4 -3
- package/src/async-init-registry.ts +60 -0
- package/src/createSyncularReact.tsx +38 -29
- package/src/index.ts +8 -0
- package/src/use-cached-async-value.ts +68 -0
|
@@ -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
|
-
|
|
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:
|
|
101
|
+
handlers: ClientSyncConfig<DB, { actorId: string }>['handlers'];
|
|
103
102
|
}
|
|
104
103
|
|
|
105
|
-
export interface SyncProviderProps<
|
|
104
|
+
export interface SyncProviderProps<
|
|
105
|
+
DB extends SyncClientDb,
|
|
106
|
+
Identity extends { actorId: string },
|
|
107
|
+
> {
|
|
106
108
|
db: Kysely<DB>;
|
|
107
109
|
transport: SyncTransport;
|
|
108
|
-
|
|
109
|
-
|
|
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<
|
|
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
|
-
|
|
428
|
-
|
|
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
|
-
|
|
478
|
-
|
|
485
|
+
sync,
|
|
486
|
+
identity,
|
|
479
487
|
clientId,
|
|
480
|
-
|
|
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
|
-
|
|
514
|
+
sync,
|
|
507
515
|
}));
|
|
508
516
|
|
|
509
517
|
useEffect(() => {
|
|
510
518
|
const changedProps: string[] = [];
|
|
511
|
-
if (actorId !== initialProps.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 (
|
|
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
|
-
}, [
|
|
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 &&
|
|
565
|
-
engine.updateSubscriptions(
|
|
573
|
+
if (isReady && resolvedSubscriptions.length > 0) {
|
|
574
|
+
engine.updateSubscriptions(resolvedSubscriptions);
|
|
566
575
|
}
|
|
567
|
-
}, [engine, isReady,
|
|
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,
|
|
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
|
+
}
|