@syncular/client-react 0.0.4-26 → 0.0.6-46
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/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 +44 -0
- package/dist/use-cached-async-value.js.map +1 -0
- package/package.json +6 -6
- package/src/__tests__/async-init-registry.test.ts +64 -0
- package/src/__tests__/use-cached-async-value.test.tsx +111 -0
- package/src/async-init-registry.ts +60 -0
- package/src/index.ts +8 -0
- package/src/use-cached-async-value.ts +67 -0
|
@@ -0,0 +1,25 @@
|
|
|
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
|
+
export interface AsyncInitRegistry<TKey, TValue> {
|
|
8
|
+
/**
|
|
9
|
+
* Run (or join) the initializer associated with a key.
|
|
10
|
+
* - First caller executes `init`.
|
|
11
|
+
* - Concurrent/future callers receive the same promise.
|
|
12
|
+
* - If `init` rejects, the key is evicted.
|
|
13
|
+
*/
|
|
14
|
+
run(key: TKey, init: () => Promise<TValue> | TValue): Promise<TValue>;
|
|
15
|
+
/**
|
|
16
|
+
* Forget a single key so a subsequent `run` executes again.
|
|
17
|
+
*/
|
|
18
|
+
invalidate(key: TKey): void;
|
|
19
|
+
/**
|
|
20
|
+
* Forget all cached entries.
|
|
21
|
+
*/
|
|
22
|
+
clear(): void;
|
|
23
|
+
}
|
|
24
|
+
export declare function createAsyncInitRegistry<TKey, TValue>(): AsyncInitRegistry<TKey, TValue>;
|
|
25
|
+
//# sourceMappingURL=async-init-registry.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"async-init-registry.d.ts","sourceRoot":"","sources":["../src/async-init-registry.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,MAAM,WAAW,iBAAiB,CAAC,IAAI,EAAE,MAAM;IAC7C;;;;;OAKG;IACH,GAAG,CAAC,GAAG,EAAE,IAAI,EAAE,IAAI,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;IAEtE;;OAEG;IACH,UAAU,CAAC,GAAG,EAAE,IAAI,GAAG,IAAI,CAAC;IAE5B;;OAEG;IACH,KAAK,IAAI,IAAI,CAAC;CACf;AAED,wBAAgB,uBAAuB,CAAC,IAAI,EAAE,MAAM,KAAK,iBAAiB,CACxE,IAAI,EACJ,MAAM,CACP,CA6BA"}
|
|
@@ -0,0 +1,32 @@
|
|
|
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
|
+
export function createAsyncInitRegistry() {
|
|
8
|
+
const cache = new Map();
|
|
9
|
+
function run(key, init) {
|
|
10
|
+
const cached = cache.get(key);
|
|
11
|
+
if (cached)
|
|
12
|
+
return cached;
|
|
13
|
+
const next = Promise.resolve().then(init);
|
|
14
|
+
cache.set(key, next);
|
|
15
|
+
void next.catch(() => {
|
|
16
|
+
if (cache.get(key) === next) {
|
|
17
|
+
cache.delete(key);
|
|
18
|
+
}
|
|
19
|
+
});
|
|
20
|
+
return next;
|
|
21
|
+
}
|
|
22
|
+
return {
|
|
23
|
+
run,
|
|
24
|
+
invalidate(key) {
|
|
25
|
+
cache.delete(key);
|
|
26
|
+
},
|
|
27
|
+
clear() {
|
|
28
|
+
cache.clear();
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
//# sourceMappingURL=async-init-registry.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"async-init-registry.js","sourceRoot":"","sources":["../src/async-init-registry.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAsBH,MAAM,UAAU,uBAAuB,GAGrC;IACA,MAAM,KAAK,GAAG,IAAI,GAAG,EAAyB,CAAC;IAE/C,SAAS,GAAG,CACV,GAAS,EACT,IAAoC,EACnB;QACjB,MAAM,MAAM,GAAG,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC;QAC9B,IAAI,MAAM;YAAE,OAAO,MAAM,CAAC;QAE1B,MAAM,IAAI,GAAG,OAAO,CAAC,OAAO,EAAE,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC1C,KAAK,CAAC,GAAG,CAAC,GAAG,EAAE,IAAI,CAAC,CAAC;QACrB,KAAK,IAAI,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC;YACpB,IAAI,KAAK,CAAC,GAAG,CAAC,GAAG,CAAC,KAAK,IAAI,EAAE,CAAC;gBAC5B,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;YACpB,CAAC;QAAA,CACF,CAAC,CAAC;QACH,OAAO,IAAI,CAAC;IAAA,CACb;IAED,OAAO;QACL,GAAG;QACH,UAAU,CAAC,GAAS,EAAE;YACpB,KAAK,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC;QAAA,CACnB;QACD,KAAK,GAAG;YACN,KAAK,CAAC,KAAK,EAAE,CAAC;QAAA,CACf;KACF,CAAC;AAAA,CACH"}
|
package/dist/index.d.ts
CHANGED
|
@@ -4,8 +4,12 @@
|
|
|
4
4
|
* Use `createSyncularReact<DB>()` to create a SyncProvider + hooks that are
|
|
5
5
|
* typed to your application's DB schema.
|
|
6
6
|
*/
|
|
7
|
+
export type { AsyncInitRegistry } from './async-init-registry';
|
|
8
|
+
export { createAsyncInitRegistry } from './async-init-registry';
|
|
7
9
|
export type { ConflictResolution, FluentMutation, MutationInput, MutationResult, MutationsHook, OutboxCommit, SyncContextValue, SyncProviderProps, SyncStatus, UseConflictsResult, UseMutationOptions, UseMutationResult, UseMutationsOptions, UseOutboxResult, UsePresenceResult, UsePresenceWithJoinOptions, UsePresenceWithJoinResult, UseQueryOptions, UseQueryResult, UseResolveConflictOptions, UseResolveConflictResult, UseSyncConnectionResult, UseSyncEngineResult, UseSyncInspectorOptions, UseSyncInspectorResult, UseSyncProgressOptions, UseSyncProgressResult, UseSyncQueryOptions, UseSyncQueryResult, UseSyncStatusOptions, UseSyncSubscriptionResult, UseSyncSubscriptionsOptions, UseSyncSubscriptionsResult, UseTransportHealthResult, } from './createSyncularReact';
|
|
8
10
|
export { createSyncularReact } from './createSyncularReact';
|
|
11
|
+
export type { UseCachedAsyncValueOptions } from './use-cached-async-value';
|
|
12
|
+
export { clearCachedAsyncValues, invalidateCachedAsyncValue, useCachedAsyncValue, } from './use-cached-async-value';
|
|
9
13
|
export type { SyncGroupChannel, SyncGroupChannelSnapshot, SyncGroupStatus, UseSyncGroupResult, } from './useSyncGroup';
|
|
10
14
|
export { useSyncGroup } from './useSyncGroup';
|
|
11
15
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,YAAY,EACV,kBAAkB,EAClB,cAAc,EACd,aAAa,EACb,cAAc,EACd,aAAa,EACb,YAAY,EACZ,gBAAgB,EAChB,iBAAiB,EACjB,UAAU,EACV,kBAAkB,EAClB,kBAAkB,EAClB,iBAAiB,EACjB,mBAAmB,EACnB,eAAe,EACf,iBAAiB,EACjB,0BAA0B,EAC1B,yBAAyB,EACzB,eAAe,EACf,cAAc,EACd,yBAAyB,EACzB,wBAAwB,EACxB,uBAAuB,EACvB,mBAAmB,EACnB,uBAAuB,EACvB,sBAAsB,EACtB,sBAAsB,EACtB,qBAAqB,EACrB,mBAAmB,EACnB,kBAAkB,EAClB,oBAAoB,EACpB,yBAAyB,EACzB,2BAA2B,EAC3B,0BAA0B,EAC1B,wBAAwB,GACzB,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AAC5D,YAAY,EACV,gBAAgB,EAChB,wBAAwB,EACxB,eAAe,EACf,kBAAkB,GACnB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,YAAY,EAAE,iBAAiB,EAAE,MAAM,uBAAuB,CAAC;AAC/D,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAChE,YAAY,EACV,kBAAkB,EAClB,cAAc,EACd,aAAa,EACb,cAAc,EACd,aAAa,EACb,YAAY,EACZ,gBAAgB,EAChB,iBAAiB,EACjB,UAAU,EACV,kBAAkB,EAClB,kBAAkB,EAClB,iBAAiB,EACjB,mBAAmB,EACnB,eAAe,EACf,iBAAiB,EACjB,0BAA0B,EAC1B,yBAAyB,EACzB,eAAe,EACf,cAAc,EACd,yBAAyB,EACzB,wBAAwB,EACxB,uBAAuB,EACvB,mBAAmB,EACnB,uBAAuB,EACvB,sBAAsB,EACtB,sBAAsB,EACtB,qBAAqB,EACrB,mBAAmB,EACnB,kBAAkB,EAClB,oBAAoB,EACpB,yBAAyB,EACzB,2BAA2B,EAC3B,0BAA0B,EAC1B,wBAAwB,GACzB,MAAM,uBAAuB,CAAC;AAE/B,OAAO,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AAC5D,YAAY,EAAE,0BAA0B,EAAE,MAAM,0BAA0B,CAAC;AAC3E,OAAO,EACL,sBAAsB,EACtB,0BAA0B,EAC1B,mBAAmB,GACpB,MAAM,0BAA0B,CAAC;AAClC,YAAY,EACV,gBAAgB,EAChB,wBAAwB,EACxB,eAAe,EACf,kBAAkB,GACnB,MAAM,gBAAgB,CAAC;AACxB,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -4,7 +4,9 @@
|
|
|
4
4
|
* Use `createSyncularReact<DB>()` to create a SyncProvider + hooks that are
|
|
5
5
|
* typed to your application's DB schema.
|
|
6
6
|
*/
|
|
7
|
+
export { createAsyncInitRegistry } from './async-init-registry.js';
|
|
7
8
|
// Re-export core client types for convenience.
|
|
8
9
|
export { createSyncularReact } from './createSyncularReact.js';
|
|
10
|
+
export { clearCachedAsyncValues, invalidateCachedAsyncValue, useCachedAsyncValue, } from './use-cached-async-value.js';
|
|
9
11
|
export { useSyncGroup } from './useSyncGroup.js';
|
|
10
12
|
//# sourceMappingURL=index.js.map
|
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAGH,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAqChE,+CAA+C;AAC/C,OAAO,EAAE,mBAAmB,EAAE,MAAM,uBAAuB,CAAC;AAE5D,OAAO,EACL,sBAAsB,EACtB,0BAA0B,EAC1B,mBAAmB,GACpB,MAAM,0BAA0B,CAAC;AAOlC,OAAO,EAAE,YAAY,EAAE,MAAM,gBAAgB,CAAC"}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface UseCachedAsyncValueOptions<TKey = unknown> {
|
|
2
|
+
/**
|
|
3
|
+
* Stable cache key shared across component instances.
|
|
4
|
+
* Defaults to the callback function identity.
|
|
5
|
+
*/
|
|
6
|
+
key?: TKey;
|
|
7
|
+
/**
|
|
8
|
+
* Additional dependencies that should re-run the callback.
|
|
9
|
+
*/
|
|
10
|
+
deps?: readonly unknown[];
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Resolve an async value with a process-wide cache outside component state.
|
|
14
|
+
* Returns the resolved value and error tuple: [value, error].
|
|
15
|
+
*/
|
|
16
|
+
export declare function useCachedAsyncValue<TValue>(run: () => Promise<TValue> | TValue, options?: UseCachedAsyncValueOptions): readonly [TValue | null, Error | null];
|
|
17
|
+
export declare function invalidateCachedAsyncValue(key: unknown): void;
|
|
18
|
+
export declare function clearCachedAsyncValues(): void;
|
|
19
|
+
//# sourceMappingURL=use-cached-async-value.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-cached-async-value.d.ts","sourceRoot":"","sources":["../src/use-cached-async-value.ts"],"names":[],"mappings":"AAOA,MAAM,WAAW,0BAA0B,CAAC,IAAI,GAAG,OAAO;IACxD;;;OAGG;IACH,GAAG,CAAC,EAAE,IAAI,CAAC;IACX;;OAEG;IACH,IAAI,CAAC,EAAE,SAAS,OAAO,EAAE,CAAC;CAC3B;AAED;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EACxC,GAAG,EAAE,MAAM,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,EACnC,OAAO,CAAC,EAAE,0BAA0B,GACnC,SAAS,CAAC,MAAM,GAAG,IAAI,EAAE,KAAK,GAAG,IAAI,CAAC,CAgCxC;AAED,wBAAgB,0BAA0B,CAAC,GAAG,EAAE,OAAO,GAAG,IAAI,CAE7D;AAED,wBAAgB,sBAAsB,IAAI,IAAI,CAE7C"}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { useEffect, useRef, useState } from 'react';
|
|
2
|
+
import { createAsyncInitRegistry } from './async-init-registry.js';
|
|
3
|
+
const cachedAsyncValueRegistry = createAsyncInitRegistry();
|
|
4
|
+
const EMPTY_DEPS = Object.freeze([]);
|
|
5
|
+
/**
|
|
6
|
+
* Resolve an async value with a process-wide cache outside component state.
|
|
7
|
+
* Returns the resolved value and error tuple: [value, error].
|
|
8
|
+
*/
|
|
9
|
+
export function useCachedAsyncValue(run, options) {
|
|
10
|
+
const [value, setValue] = useState(null);
|
|
11
|
+
const [error, setError] = useState(null);
|
|
12
|
+
const runRef = useRef(run);
|
|
13
|
+
runRef.current = run;
|
|
14
|
+
const key = options?.key ?? run;
|
|
15
|
+
const deps = options?.deps ?? EMPTY_DEPS;
|
|
16
|
+
useEffect(() => {
|
|
17
|
+
let cancelled = false;
|
|
18
|
+
setValue(null);
|
|
19
|
+
setError(null);
|
|
20
|
+
void cachedAsyncValueRegistry
|
|
21
|
+
.run(key, () => runRef.current())
|
|
22
|
+
.then((resolved) => {
|
|
23
|
+
if (cancelled)
|
|
24
|
+
return;
|
|
25
|
+
setValue(resolved);
|
|
26
|
+
})
|
|
27
|
+
.catch((cause) => {
|
|
28
|
+
if (cancelled)
|
|
29
|
+
return;
|
|
30
|
+
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
31
|
+
});
|
|
32
|
+
return () => {
|
|
33
|
+
cancelled = true;
|
|
34
|
+
};
|
|
35
|
+
}, [key, ...deps]);
|
|
36
|
+
return [value, error];
|
|
37
|
+
}
|
|
38
|
+
export function invalidateCachedAsyncValue(key) {
|
|
39
|
+
cachedAsyncValueRegistry.invalidate(key);
|
|
40
|
+
}
|
|
41
|
+
export function clearCachedAsyncValues() {
|
|
42
|
+
cachedAsyncValueRegistry.clear();
|
|
43
|
+
}
|
|
44
|
+
//# sourceMappingURL=use-cached-async-value.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"use-cached-async-value.js","sourceRoot":"","sources":["../src/use-cached-async-value.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,SAAS,EAAE,MAAM,EAAE,QAAQ,EAAE,MAAM,OAAO,CAAC;AACpD,OAAO,EAAE,uBAAuB,EAAE,MAAM,uBAAuB,CAAC;AAEhE,MAAM,wBAAwB,GAAG,uBAAuB,EAAoB,CAAC;AAE7E,MAAM,UAAU,GAAuB,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC;AAczD;;;GAGG;AACH,MAAM,UAAU,mBAAmB,CACjC,GAAmC,EACnC,OAAoC,EACI;IACxC,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,QAAQ,CAAgB,IAAI,CAAC,CAAC;IACxD,MAAM,CAAC,KAAK,EAAE,QAAQ,CAAC,GAAG,QAAQ,CAAe,IAAI,CAAC,CAAC;IAEvD,MAAM,MAAM,GAAG,MAAM,CAAC,GAAG,CAAC,CAAC;IAC3B,MAAM,CAAC,OAAO,GAAG,GAAG,CAAC;IAErB,MAAM,GAAG,GAAG,OAAO,EAAE,GAAG,IAAI,GAAG,CAAC;IAChC,MAAM,IAAI,GAAG,OAAO,EAAE,IAAI,IAAI,UAAU,CAAC;IAEzC,SAAS,CAAC,GAAG,EAAE,CAAC;QACd,IAAI,SAAS,GAAG,KAAK,CAAC;QACtB,QAAQ,CAAC,IAAI,CAAC,CAAC;QACf,QAAQ,CAAC,IAAI,CAAC,CAAC;QAEf,KAAK,wBAAwB;aAC1B,GAAG,CAAC,GAAG,EAAE,GAAG,EAAE,CAAC,MAAM,CAAC,OAAO,EAAE,CAAC;aAChC,IAAI,CAAC,CAAC,QAAQ,EAAE,EAAE,CAAC;YAClB,IAAI,SAAS;gBAAE,OAAO;YACtB,QAAQ,CAAC,QAAkB,CAAC,CAAC;QAAA,CAC9B,CAAC;aACD,KAAK,CAAC,CAAC,KAAc,EAAE,EAAE,CAAC;YACzB,IAAI,SAAS;gBAAE,OAAO;YACtB,QAAQ,CAAC,KAAK,YAAY,KAAK,CAAC,CAAC,CAAC,KAAK,CAAC,CAAC,CAAC,IAAI,KAAK,CAAC,MAAM,CAAC,KAAK,CAAC,CAAC,CAAC,CAAC;QAAA,CACrE,CAAC,CAAC;QAEL,OAAO,GAAG,EAAE,CAAC;YACX,SAAS,GAAG,IAAI,CAAC;QAAA,CAClB,CAAC;IAAA,CACH,EAAE,CAAC,GAAG,EAAE,GAAG,IAAI,CAAC,CAAC,CAAC;IAEnB,OAAO,CAAC,KAAK,EAAE,KAAK,CAAU,CAAC;AAAA,CAChC;AAED,MAAM,UAAU,0BAA0B,CAAC,GAAY,EAAQ;IAC7D,wBAAwB,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC;AAAA,CAC1C;AAED,MAAM,UAAU,sBAAsB,GAAS;IAC7C,wBAAwB,CAAC,KAAK,EAAE,CAAC;AAAA,CAClC"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/client-react",
|
|
3
|
-
"version": "0.0.
|
|
3
|
+
"version": "0.0.6-46",
|
|
4
4
|
"description": "React hooks and bindings for the Syncular client",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -44,20 +44,20 @@
|
|
|
44
44
|
"release": "bunx syncular-publish"
|
|
45
45
|
},
|
|
46
46
|
"dependencies": {
|
|
47
|
-
"@syncular/client": "0.0.
|
|
47
|
+
"@syncular/client": "0.0.6-46"
|
|
48
48
|
},
|
|
49
49
|
"peerDependencies": {
|
|
50
50
|
"kysely": "^0.28.0",
|
|
51
51
|
"react": "^18.0.0 || ^19.0.0"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
|
-
"@happy-dom/global-registrator": "^20.6.
|
|
54
|
+
"@happy-dom/global-registrator": "^20.6.3",
|
|
55
55
|
"@syncular/config": "0.0.0",
|
|
56
|
-
"@syncular/dialect-bun-sqlite": "0.0.
|
|
57
|
-
"@syncular/testkit": "0.0.
|
|
56
|
+
"@syncular/dialect-bun-sqlite": "0.0.6-46",
|
|
57
|
+
"@syncular/testkit": "0.0.6-46",
|
|
58
58
|
"@testing-library/react": "^16.3.2",
|
|
59
59
|
"@types/react": "^19.2.14",
|
|
60
|
-
"happy-dom": "^20.6.
|
|
60
|
+
"happy-dom": "^20.6.3",
|
|
61
61
|
"kysely": "*",
|
|
62
62
|
"kysely-bun-sqlite": "^0.4.0",
|
|
63
63
|
"react": "^19.2.4",
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { createAsyncInitRegistry } from '../async-init-registry';
|
|
3
|
+
|
|
4
|
+
describe('createAsyncInitRegistry', () => {
|
|
5
|
+
it('runs initializer once per key and shares the same result', async () => {
|
|
6
|
+
const registry = createAsyncInitRegistry<string, string>();
|
|
7
|
+
let runs = 0;
|
|
8
|
+
|
|
9
|
+
const first = registry.run('client-a', async () => {
|
|
10
|
+
runs += 1;
|
|
11
|
+
return 'ok';
|
|
12
|
+
});
|
|
13
|
+
const second = registry.run('client-a', async () => {
|
|
14
|
+
runs += 1;
|
|
15
|
+
return 'unexpected';
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
await expect(first).resolves.toBe('ok');
|
|
19
|
+
await expect(second).resolves.toBe('ok');
|
|
20
|
+
expect(runs).toBe(1);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it('evicts failed initializers so retry can succeed', async () => {
|
|
24
|
+
const registry = createAsyncInitRegistry<string, string>();
|
|
25
|
+
let runs = 0;
|
|
26
|
+
|
|
27
|
+
await expect(
|
|
28
|
+
registry.run('client-a', async () => {
|
|
29
|
+
runs += 1;
|
|
30
|
+
throw new Error('boom');
|
|
31
|
+
})
|
|
32
|
+
).rejects.toThrow('boom');
|
|
33
|
+
|
|
34
|
+
await expect(
|
|
35
|
+
registry.run('client-a', async () => {
|
|
36
|
+
runs += 1;
|
|
37
|
+
return 'recovered';
|
|
38
|
+
})
|
|
39
|
+
).resolves.toBe('recovered');
|
|
40
|
+
|
|
41
|
+
expect(runs).toBe(2);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('supports explicit invalidation', async () => {
|
|
45
|
+
const registry = createAsyncInitRegistry<string, number>();
|
|
46
|
+
let seed = 0;
|
|
47
|
+
|
|
48
|
+
await expect(
|
|
49
|
+
registry.run('client-a', async () => {
|
|
50
|
+
seed += 1;
|
|
51
|
+
return seed;
|
|
52
|
+
})
|
|
53
|
+
).resolves.toBe(1);
|
|
54
|
+
|
|
55
|
+
registry.invalidate('client-a');
|
|
56
|
+
|
|
57
|
+
await expect(
|
|
58
|
+
registry.run('client-a', async () => {
|
|
59
|
+
seed += 1;
|
|
60
|
+
return seed;
|
|
61
|
+
})
|
|
62
|
+
).resolves.toBe(2);
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { describe, expect, it } from 'bun:test';
|
|
2
|
+
import { renderHook, waitFor } from '@testing-library/react';
|
|
3
|
+
import type { ReactNode } from 'react';
|
|
4
|
+
import { StrictMode } from 'react';
|
|
5
|
+
import {
|
|
6
|
+
clearCachedAsyncValues,
|
|
7
|
+
useCachedAsyncValue,
|
|
8
|
+
} from '../use-cached-async-value';
|
|
9
|
+
|
|
10
|
+
function strictModeWrapper(props: { children: ReactNode }) {
|
|
11
|
+
return <StrictMode>{props.children}</StrictMode>;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('useCachedAsyncValue', () => {
|
|
15
|
+
it('deduplicates StrictMode initialization by key', async () => {
|
|
16
|
+
clearCachedAsyncValues();
|
|
17
|
+
let runs = 0;
|
|
18
|
+
|
|
19
|
+
const { result } = renderHook(
|
|
20
|
+
() =>
|
|
21
|
+
useCachedAsyncValue(
|
|
22
|
+
async () => {
|
|
23
|
+
runs += 1;
|
|
24
|
+
return 'ready';
|
|
25
|
+
},
|
|
26
|
+
{ key: 'strictmode-init' }
|
|
27
|
+
),
|
|
28
|
+
{ wrapper: strictModeWrapper }
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
await waitFor(() => {
|
|
32
|
+
expect(result.current[0]).toBe('ready');
|
|
33
|
+
});
|
|
34
|
+
expect(result.current[1]).toBeNull();
|
|
35
|
+
expect(runs).toBe(1);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('retries after a failure when dependencies trigger rerun', async () => {
|
|
39
|
+
clearCachedAsyncValues();
|
|
40
|
+
let runs = 0;
|
|
41
|
+
|
|
42
|
+
const { result, rerender } = renderHook(
|
|
43
|
+
({ attempt }: { attempt: number }) =>
|
|
44
|
+
useCachedAsyncValue(
|
|
45
|
+
async () => {
|
|
46
|
+
runs += 1;
|
|
47
|
+
if (attempt === 0) {
|
|
48
|
+
throw new Error('boom');
|
|
49
|
+
}
|
|
50
|
+
return 'recovered';
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
key: 'retryable-init',
|
|
54
|
+
deps: [attempt],
|
|
55
|
+
}
|
|
56
|
+
),
|
|
57
|
+
{
|
|
58
|
+
initialProps: { attempt: 0 },
|
|
59
|
+
}
|
|
60
|
+
);
|
|
61
|
+
|
|
62
|
+
await waitFor(() => {
|
|
63
|
+
expect(result.current[1]?.message).toBe('boom');
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
rerender({ attempt: 1 });
|
|
67
|
+
|
|
68
|
+
await waitFor(() => {
|
|
69
|
+
expect(result.current[0]).toBe('recovered');
|
|
70
|
+
});
|
|
71
|
+
expect(result.current[1]).toBeNull();
|
|
72
|
+
expect(runs).toBe(2);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('reuses cached values across component remounts', async () => {
|
|
76
|
+
clearCachedAsyncValues();
|
|
77
|
+
let runs = 0;
|
|
78
|
+
|
|
79
|
+
const first = renderHook(() =>
|
|
80
|
+
useCachedAsyncValue(
|
|
81
|
+
async () => {
|
|
82
|
+
runs += 1;
|
|
83
|
+
return 7;
|
|
84
|
+
},
|
|
85
|
+
{ key: 'shared-init' }
|
|
86
|
+
)
|
|
87
|
+
);
|
|
88
|
+
|
|
89
|
+
await waitFor(() => {
|
|
90
|
+
expect(first.result.current[0]).toBe(7);
|
|
91
|
+
});
|
|
92
|
+
first.unmount();
|
|
93
|
+
|
|
94
|
+
const second = renderHook(() =>
|
|
95
|
+
useCachedAsyncValue(
|
|
96
|
+
async () => {
|
|
97
|
+
runs += 1;
|
|
98
|
+
return 9;
|
|
99
|
+
},
|
|
100
|
+
{ key: 'shared-init' }
|
|
101
|
+
)
|
|
102
|
+
);
|
|
103
|
+
|
|
104
|
+
await waitFor(() => {
|
|
105
|
+
expect(second.result.current[0]).toBe(7);
|
|
106
|
+
});
|
|
107
|
+
expect(second.result.current[1]).toBeNull();
|
|
108
|
+
expect(runs).toBe(1);
|
|
109
|
+
second.unmount();
|
|
110
|
+
});
|
|
111
|
+
});
|
|
@@ -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
|
+
}
|
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,67 @@
|
|
|
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
|
+
|
|
8
|
+
export interface UseCachedAsyncValueOptions<TKey = unknown> {
|
|
9
|
+
/**
|
|
10
|
+
* Stable cache key shared across component instances.
|
|
11
|
+
* Defaults to the callback function identity.
|
|
12
|
+
*/
|
|
13
|
+
key?: TKey;
|
|
14
|
+
/**
|
|
15
|
+
* Additional dependencies that should re-run the callback.
|
|
16
|
+
*/
|
|
17
|
+
deps?: readonly unknown[];
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Resolve an async value with a process-wide cache outside component state.
|
|
22
|
+
* Returns the resolved value and error tuple: [value, error].
|
|
23
|
+
*/
|
|
24
|
+
export function useCachedAsyncValue<TValue>(
|
|
25
|
+
run: () => Promise<TValue> | TValue,
|
|
26
|
+
options?: UseCachedAsyncValueOptions
|
|
27
|
+
): readonly [TValue | null, Error | null] {
|
|
28
|
+
const [value, setValue] = useState<TValue | null>(null);
|
|
29
|
+
const [error, setError] = useState<Error | null>(null);
|
|
30
|
+
|
|
31
|
+
const runRef = useRef(run);
|
|
32
|
+
runRef.current = run;
|
|
33
|
+
|
|
34
|
+
const key = options?.key ?? run;
|
|
35
|
+
const deps = options?.deps ?? EMPTY_DEPS;
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
let cancelled = false;
|
|
39
|
+
setValue(null);
|
|
40
|
+
setError(null);
|
|
41
|
+
|
|
42
|
+
void cachedAsyncValueRegistry
|
|
43
|
+
.run(key, () => runRef.current())
|
|
44
|
+
.then((resolved) => {
|
|
45
|
+
if (cancelled) return;
|
|
46
|
+
setValue(resolved as TValue);
|
|
47
|
+
})
|
|
48
|
+
.catch((cause: unknown) => {
|
|
49
|
+
if (cancelled) return;
|
|
50
|
+
setError(cause instanceof Error ? cause : new Error(String(cause)));
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return () => {
|
|
54
|
+
cancelled = true;
|
|
55
|
+
};
|
|
56
|
+
}, [key, ...deps]);
|
|
57
|
+
|
|
58
|
+
return [value, error] as const;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function invalidateCachedAsyncValue(key: unknown): void {
|
|
62
|
+
cachedAsyncValueRegistry.invalidate(key);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function clearCachedAsyncValues(): void {
|
|
66
|
+
cachedAsyncValueRegistry.clear();
|
|
67
|
+
}
|