@syncular/react 0.4.0 → 0.5.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/README.md +134 -117
- package/dist/client.d.ts +13 -1
- package/dist/client.js +4 -0
- package/dist/index.d.ts +5 -4
- package/dist/index.js +5 -4
- package/dist/provider.d.ts +6 -1
- package/dist/provider.js +53 -4
- package/dist/resource.d.ts +18 -0
- package/dist/resource.js +56 -0
- package/dist/use-client.d.ts +2 -0
- package/dist/use-client.js +8 -1
- package/dist/use-conflicts.d.ts +2 -5
- package/dist/use-conflicts.js +12 -35
- package/dist/use-mutation.d.ts +22 -12
- package/dist/use-mutation.js +61 -21
- package/dist/use-query.d.ts +10 -3
- package/dist/use-query.js +7 -2
- package/dist/use-raw-sql.d.ts +15 -29
- package/dist/use-raw-sql.js +54 -186
- package/dist/use-sync-status.d.ts +1 -18
- package/dist/use-sync-status.js +15 -65
- package/dist/use-window.d.ts +13 -25
- package/dist/use-window.js +55 -70
- package/package.json +4 -4
- package/src/client.ts +29 -0
- package/src/index.ts +26 -4
- package/src/provider.ts +89 -7
- package/src/resource.ts +77 -0
- package/src/use-client.ts +12 -1
- package/src/use-conflicts.ts +18 -37
- package/src/use-mutation.ts +126 -25
- package/src/use-query.ts +27 -8
- package/src/use-raw-sql.ts +83 -212
- package/src/use-sync-status.ts +20 -73
- package/src/use-window.ts +90 -81
- package/dist/query-churn.d.ts +0 -109
- package/dist/query-churn.js +0 -276
- package/src/query-churn.ts +0 -317
package/dist/use-window.d.ts
CHANGED
|
@@ -1,33 +1,21 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `useWindow(base)` — the windowed-sync surface for a component
|
|
3
|
-
* (SPEC.md §4.8 / DESIGN-eviction.md W1, I3). It manages the live window
|
|
4
|
-
* units for a base and exposes the **completeness oracle**: which scope
|
|
5
|
-
* values are held locally in full, so a consumer can render "this data may
|
|
6
|
-
* be partial" honestly instead of silently serving a partial replica as
|
|
7
|
-
* complete.
|
|
8
|
-
*
|
|
9
|
-
* - `setWindow(units)` swaps the live set (added units bootstrap via the
|
|
10
|
-
* image lane; removed units are evicted, fused with unsubscription).
|
|
11
|
-
* - `units` is the current windowed-in set, `pending` the subset whose
|
|
12
|
-
* bootstrap has not yet landed (re-read on mount and whenever the base's
|
|
13
|
-
* table is invalidated — a deferred eviction draining, a re-entry
|
|
14
|
-
* bootstrapping, or a bootstrap completing all update the verdict).
|
|
15
|
-
* - `isComplete(unit)` is the per-value verdict: registered AND
|
|
16
|
-
* bootstrap-complete. A live query whose scope footprint includes a
|
|
17
|
-
* non-`isComplete` unit is a **window miss or still loading** — widen,
|
|
18
|
-
* wait, or show partial, never claim complete. Between `setWindow` and
|
|
19
|
-
* the unit's bootstrap landing the verdict is `false` (the local replica
|
|
20
|
-
* is empty or partial there — never a false "empty" render).
|
|
21
|
-
*/
|
|
22
1
|
import { type WindowBase } from '@syncular/client';
|
|
23
2
|
export interface UseWindowResult {
|
|
24
|
-
/** The scope values currently windowed-in for this base. */
|
|
25
3
|
readonly units: readonly string[];
|
|
26
|
-
/** Registered units whose bootstrap has not yet completed (§4.8). */
|
|
27
4
|
readonly pending: readonly string[];
|
|
28
|
-
/**
|
|
5
|
+
/** Update this component's claim; the store applies the union of all claims. */
|
|
29
6
|
readonly setWindow: (units: readonly string[]) => Promise<void>;
|
|
30
|
-
/** True iff `unit` is windowed-in AND bootstrapped (answerable, I3). */
|
|
31
7
|
readonly isComplete: (unit: string) => boolean;
|
|
32
8
|
}
|
|
9
|
+
export interface UseRetainedWindowResult {
|
|
10
|
+
/** True until the retained working set has reached the core window. */
|
|
11
|
+
readonly isPending: boolean;
|
|
12
|
+
/** Registration failure, if the host rejects the window change. */
|
|
13
|
+
readonly error: Error | undefined;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Retain a known working set for the lifetime of this component. Retention
|
|
17
|
+
* composes with generated query coverage and other owners; cleanup releases
|
|
18
|
+
* only this hook's claim. Ordinary selected queries still claim themselves.
|
|
19
|
+
*/
|
|
20
|
+
export declare function useRetainedWindow(base: WindowBase, units: readonly string[]): UseRetainedWindowResult;
|
|
33
21
|
export declare function useWindow(base: WindowBase): UseWindowResult;
|
package/dist/use-window.js
CHANGED
|
@@ -1,79 +1,64 @@
|
|
|
1
|
+
import { canonicalValue, windowComplete, } from '@syncular/client';
|
|
2
|
+
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, } from 'react';
|
|
3
|
+
import { useReactiveStore } from './use-client.js';
|
|
1
4
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
* values are held locally in full, so a consumer can render "this data may
|
|
6
|
-
* be partial" honestly instead of silently serving a partial replica as
|
|
7
|
-
* complete.
|
|
8
|
-
*
|
|
9
|
-
* - `setWindow(units)` swaps the live set (added units bootstrap via the
|
|
10
|
-
* image lane; removed units are evicted, fused with unsubscription).
|
|
11
|
-
* - `units` is the current windowed-in set, `pending` the subset whose
|
|
12
|
-
* bootstrap has not yet landed (re-read on mount and whenever the base's
|
|
13
|
-
* table is invalidated — a deferred eviction draining, a re-entry
|
|
14
|
-
* bootstrapping, or a bootstrap completing all update the verdict).
|
|
15
|
-
* - `isComplete(unit)` is the per-value verdict: registered AND
|
|
16
|
-
* bootstrap-complete. A live query whose scope footprint includes a
|
|
17
|
-
* non-`isComplete` unit is a **window miss or still loading** — widen,
|
|
18
|
-
* wait, or show partial, never claim complete. Between `setWindow` and
|
|
19
|
-
* the unit's bootstrap landing the verdict is `false` (the local replica
|
|
20
|
-
* is empty or partial there — never a false "empty" render).
|
|
5
|
+
* Retain a known working set for the lifetime of this component. Retention
|
|
6
|
+
* composes with generated query coverage and other owners; cleanup releases
|
|
7
|
+
* only this hook's claim. Ordinary selected queries still claim themselves.
|
|
21
8
|
*/
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
const baseRef = useRef(base);
|
|
35
|
-
baseRef.current = base;
|
|
36
|
-
// `baseKey` re-keys the effect without being read in the body (biome cannot
|
|
37
|
-
// see the ref indirection) — the dep list is pinned deliberately.
|
|
38
|
-
// biome-ignore lint/correctness/useExhaustiveDependencies: baseKey re-keys the effect for a fresh base object
|
|
9
|
+
export function useRetainedWindow(base, units) {
|
|
10
|
+
const store = useReactiveStore();
|
|
11
|
+
const baseIdentity = canonicalValue(base);
|
|
12
|
+
const unitsIdentity = canonicalValue([...new Set(units)].sort());
|
|
13
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: canonical identities represent the complete values
|
|
14
|
+
const stableBase = useMemo(() => base, [baseIdentity]);
|
|
15
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: unitsIdentity represents the normalized unit set
|
|
16
|
+
const stableUnits = useMemo(() => [...new Set(units)].sort(), [unitsIdentity]);
|
|
17
|
+
const [state, setState] = useState({
|
|
18
|
+
isPending: true,
|
|
19
|
+
error: undefined,
|
|
20
|
+
});
|
|
39
21
|
useEffect(() => {
|
|
40
|
-
let
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
22
|
+
let active = true;
|
|
23
|
+
setState((current) => current.isPending && current.error === undefined
|
|
24
|
+
? current
|
|
25
|
+
: { isPending: true, error: undefined });
|
|
26
|
+
const retention = store.retainWindow(stableBase, stableUnits);
|
|
27
|
+
void retention.ready.then(() => {
|
|
28
|
+
if (active)
|
|
29
|
+
setState({ isPending: false, error: undefined });
|
|
30
|
+
}, (caught) => {
|
|
31
|
+
if (!active)
|
|
32
|
+
return;
|
|
33
|
+
setState({
|
|
34
|
+
isPending: false,
|
|
35
|
+
error: caught instanceof Error ? caught : new Error(String(caught)),
|
|
49
36
|
});
|
|
50
|
-
};
|
|
51
|
-
read();
|
|
52
|
-
// Re-read when the base's table changes locally: a deferred eviction
|
|
53
|
-
// (E1) completing or a re-entry bootstrapping both invalidate it.
|
|
54
|
-
const unsubscribe = client.onInvalidate((event) => {
|
|
55
|
-
if (event.tables.has(baseRef.current.table))
|
|
56
|
-
read();
|
|
57
37
|
});
|
|
58
38
|
return () => {
|
|
59
|
-
|
|
60
|
-
|
|
39
|
+
active = false;
|
|
40
|
+
retention.release();
|
|
61
41
|
};
|
|
62
|
-
}, [
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
42
|
+
}, [stableBase, stableUnits, store]);
|
|
43
|
+
return state;
|
|
44
|
+
}
|
|
45
|
+
export function useWindow(base) {
|
|
46
|
+
const store = useReactiveStore();
|
|
47
|
+
const owner = useRef(Symbol('useWindow'));
|
|
48
|
+
const baseIdentity = canonicalValue(base);
|
|
49
|
+
// Window bases are value objects commonly recreated during render. Preserve
|
|
50
|
+
// the prior object while its canonical value is unchanged.
|
|
51
|
+
// biome-ignore lint/correctness/useExhaustiveDependencies: baseIdentity represents the complete value
|
|
52
|
+
const stableBase = useMemo(() => base, [baseIdentity]);
|
|
53
|
+
const entry = useMemo(() => store.window(stableBase), [store, stableBase]);
|
|
54
|
+
const state = useSyncExternalStore(entry.subscribe, entry.getSnapshot, entry.getSnapshot);
|
|
55
|
+
useEffect(() => () => store.releaseWindowClaims(owner.current), [store]);
|
|
56
|
+
const setWindow = useCallback((units) => store.setWindowClaim(owner.current, stableBase, units), [stableBase, store]);
|
|
77
57
|
const isComplete = useCallback((unit) => windowComplete(state, unit), [state]);
|
|
78
|
-
return {
|
|
58
|
+
return {
|
|
59
|
+
units: state.units,
|
|
60
|
+
pending: state.pending,
|
|
61
|
+
setWindow,
|
|
62
|
+
isComplete,
|
|
63
|
+
};
|
|
79
64
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@syncular/react",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "React hooks for Syncular offline-first sync",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"author": "Benjamin Kniffler",
|
|
@@ -48,15 +48,15 @@
|
|
|
48
48
|
"test": "bun test --preload ./test/setup.ts"
|
|
49
49
|
},
|
|
50
50
|
"dependencies": {
|
|
51
|
-
"@syncular/client": "0.
|
|
51
|
+
"@syncular/client": "0.5.0"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
54
|
"react": ">=18.0.0"
|
|
55
55
|
},
|
|
56
56
|
"devDependencies": {
|
|
57
57
|
"@happy-dom/global-registrator": "^15.11.0",
|
|
58
|
-
"@syncular/core": "0.
|
|
59
|
-
"@syncular/server": "0.
|
|
58
|
+
"@syncular/core": "0.5.0",
|
|
59
|
+
"@syncular/server": "0.5.0",
|
|
60
60
|
"@testing-library/react": "^16.1.0",
|
|
61
61
|
"@types/react": "^18.3.0",
|
|
62
62
|
"react": "^18.3.1",
|
package/src/client.ts
CHANGED
|
@@ -12,15 +12,19 @@
|
|
|
12
12
|
* never has to care which core it holds.
|
|
13
13
|
*/
|
|
14
14
|
import type {
|
|
15
|
+
ClientChangeListener,
|
|
15
16
|
ConflictRecord,
|
|
16
17
|
InvalidationListener,
|
|
17
18
|
LeaseState,
|
|
18
19
|
MutationInput,
|
|
19
20
|
PresencePeer,
|
|
21
|
+
QueryReadSpec,
|
|
22
|
+
QuerySnapshot,
|
|
20
23
|
RejectionRecord,
|
|
21
24
|
SchemaFloor,
|
|
22
25
|
SqlRow,
|
|
23
26
|
SqlValue,
|
|
27
|
+
SyncStatusSnapshot,
|
|
24
28
|
WindowBase,
|
|
25
29
|
WindowState,
|
|
26
30
|
} from '@syncular/client';
|
|
@@ -32,6 +36,7 @@ import type {
|
|
|
32
36
|
* never reach past this surface.
|
|
33
37
|
*/
|
|
34
38
|
export interface SyncClientLike {
|
|
39
|
+
onChange(listener: ClientChangeListener): () => void;
|
|
35
40
|
onInvalidate(listener: InvalidationListener): () => void;
|
|
36
41
|
onPresence(listener: (scopeKey: string) => void): () => void;
|
|
37
42
|
query(
|
|
@@ -39,6 +44,16 @@ export interface SyncClientLike {
|
|
|
39
44
|
params?: readonly SqlValue[],
|
|
40
45
|
): SqlRow[] | Promise<SqlRow[]>;
|
|
41
46
|
mutate(mutations: readonly MutationInput[]): string | Promise<string>;
|
|
47
|
+
patch(
|
|
48
|
+
table: string,
|
|
49
|
+
rowId: string,
|
|
50
|
+
partial: Readonly<Record<string, unknown>>,
|
|
51
|
+
options?: { readonly baseVersion?: number },
|
|
52
|
+
): string | Promise<string>;
|
|
53
|
+
querySnapshot<Row = SqlRow>(
|
|
54
|
+
spec: QueryReadSpec,
|
|
55
|
+
): QuerySnapshot<Row> | Promise<QuerySnapshot<Row>>;
|
|
56
|
+
statusSnapshot(): SyncStatusSnapshot | Promise<SyncStatusSnapshot>;
|
|
42
57
|
conflicts:
|
|
43
58
|
| readonly ConflictRecord[]
|
|
44
59
|
| (() => readonly ConflictRecord[] | Promise<readonly ConflictRecord[]>);
|
|
@@ -88,10 +103,19 @@ function resolveMember<T>(
|
|
|
88
103
|
|
|
89
104
|
/** The uniform async facade the hooks actually call. */
|
|
90
105
|
export interface NormalizedClient {
|
|
106
|
+
onChange(listener: ClientChangeListener): () => void;
|
|
91
107
|
onInvalidate(listener: InvalidationListener): () => void;
|
|
92
108
|
onPresence(listener: (scopeKey: string) => void): () => void;
|
|
93
109
|
query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
|
|
94
110
|
mutate(mutations: readonly MutationInput[]): Promise<string>;
|
|
111
|
+
patch(
|
|
112
|
+
table: string,
|
|
113
|
+
rowId: string,
|
|
114
|
+
partial: Readonly<Record<string, unknown>>,
|
|
115
|
+
options?: { readonly baseVersion?: number },
|
|
116
|
+
): Promise<string>;
|
|
117
|
+
querySnapshot<Row = SqlRow>(spec: QueryReadSpec): Promise<QuerySnapshot<Row>>;
|
|
118
|
+
statusSnapshot(): Promise<SyncStatusSnapshot>;
|
|
95
119
|
conflicts(): Promise<readonly ConflictRecord[]>;
|
|
96
120
|
rejections(): Promise<readonly RejectionRecord[]>;
|
|
97
121
|
schemaFloor(): Promise<SchemaFloor | undefined>;
|
|
@@ -110,10 +134,15 @@ export interface NormalizedClient {
|
|
|
110
134
|
|
|
111
135
|
export function normalizeClient(client: SyncClientLike): NormalizedClient {
|
|
112
136
|
return {
|
|
137
|
+
onChange: (listener) => client.onChange(listener),
|
|
113
138
|
onInvalidate: (listener) => client.onInvalidate(listener),
|
|
114
139
|
onPresence: (listener) => client.onPresence(listener),
|
|
115
140
|
query: (sql, params) => Promise.resolve(client.query(sql, params)),
|
|
116
141
|
mutate: (mutations) => Promise.resolve(client.mutate(mutations)),
|
|
142
|
+
patch: (table, rowId, partial, options) =>
|
|
143
|
+
Promise.resolve(client.patch(table, rowId, partial, options)),
|
|
144
|
+
querySnapshot: (spec) => Promise.resolve(client.querySnapshot(spec)),
|
|
145
|
+
statusSnapshot: () => Promise.resolve(client.statusSnapshot()),
|
|
117
146
|
conflicts: () => resolveMember(client, 'conflicts'),
|
|
118
147
|
rejections: () => resolveMember(client, 'rejections'),
|
|
119
148
|
schemaFloor: () => resolveMember(client, 'schemaFloor'),
|
package/src/index.ts
CHANGED
|
@@ -16,10 +16,27 @@ export type {
|
|
|
16
16
|
// the exact normalizer the bindings use.
|
|
17
17
|
export { normalizeClient } from './client';
|
|
18
18
|
export { inferTables } from './infer-tables';
|
|
19
|
-
export {
|
|
20
|
-
|
|
19
|
+
export {
|
|
20
|
+
SyncContext,
|
|
21
|
+
SyncProvider,
|
|
22
|
+
type SyncProviderProps,
|
|
23
|
+
SyncStoreContext,
|
|
24
|
+
} from './provider';
|
|
25
|
+
export {
|
|
26
|
+
createSyncClientResource,
|
|
27
|
+
isSyncClientResource,
|
|
28
|
+
type SyncClientResource,
|
|
29
|
+
type SyncClientResourceSnapshot,
|
|
30
|
+
} from './resource';
|
|
31
|
+
export { useReactiveStore, useSyncClient } from './use-client';
|
|
21
32
|
export { type UseConflictsResult, useConflicts } from './use-conflicts';
|
|
22
|
-
export {
|
|
33
|
+
export {
|
|
34
|
+
type SyncTableDescriptor,
|
|
35
|
+
type UseMutationOptions,
|
|
36
|
+
type UseMutationResult,
|
|
37
|
+
type UseTableMutationResult,
|
|
38
|
+
useMutation,
|
|
39
|
+
} from './use-mutation';
|
|
23
40
|
export { usePresence } from './use-presence';
|
|
24
41
|
export {
|
|
25
42
|
type NamedQueryDescriptor,
|
|
@@ -31,4 +48,9 @@ export {
|
|
|
31
48
|
useRawSql,
|
|
32
49
|
} from './use-raw-sql';
|
|
33
50
|
export { type SyncStatus, useSyncStatus } from './use-sync-status';
|
|
34
|
-
export {
|
|
51
|
+
export {
|
|
52
|
+
type UseRetainedWindowResult,
|
|
53
|
+
type UseWindowResult,
|
|
54
|
+
useRetainedWindow,
|
|
55
|
+
useWindow,
|
|
56
|
+
} from './use-window';
|
package/src/provider.ts
CHANGED
|
@@ -5,32 +5,114 @@
|
|
|
5
5
|
* package typechecks under the repo's `.ts`-only root tsconfig with no jsx
|
|
6
6
|
* setting — the bindings are plain function components either way.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import { ReactiveClientStore } from '@syncular/client';
|
|
9
|
+
import {
|
|
10
|
+
createContext,
|
|
11
|
+
createElement,
|
|
12
|
+
type ReactNode,
|
|
13
|
+
useEffect,
|
|
14
|
+
useMemo,
|
|
15
|
+
useSyncExternalStore,
|
|
16
|
+
} from 'react';
|
|
9
17
|
import {
|
|
10
18
|
type NormalizedClient,
|
|
11
19
|
normalizeClient,
|
|
12
20
|
type SyncClientLike,
|
|
13
21
|
} from './client';
|
|
22
|
+
import { isSyncClientResource, type SyncClientResource } from './resource';
|
|
14
23
|
|
|
15
24
|
export const SyncContext = createContext<NormalizedClient | undefined>(
|
|
16
25
|
undefined,
|
|
17
26
|
);
|
|
27
|
+
export const SyncStoreContext = createContext<ReactiveClientStore | undefined>(
|
|
28
|
+
undefined,
|
|
29
|
+
);
|
|
18
30
|
|
|
19
31
|
export interface SyncProviderProps {
|
|
20
32
|
/** A `SyncClient` (direct) or `SyncClientHandle` (worker) — both satisfy it. */
|
|
33
|
+
readonly client: SyncClientLike | SyncClientResource;
|
|
34
|
+
readonly children?: ReactNode;
|
|
35
|
+
readonly fallback?: ReactNode;
|
|
36
|
+
readonly renderError?: (error: Error) => ReactNode;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
interface ClientRecord {
|
|
40
|
+
readonly normalized: NormalizedClient;
|
|
41
|
+
readonly store: ReactiveClientStore;
|
|
42
|
+
refs: number;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const stores = new WeakMap<object, ClientRecord>();
|
|
46
|
+
|
|
47
|
+
function recordFor(client: SyncClientLike): ClientRecord {
|
|
48
|
+
const key = client as object;
|
|
49
|
+
let record = stores.get(key);
|
|
50
|
+
if (record === undefined) {
|
|
51
|
+
const normalized = normalizeClient(client);
|
|
52
|
+
record = {
|
|
53
|
+
normalized,
|
|
54
|
+
store: new ReactiveClientStore(normalized),
|
|
55
|
+
refs: 0,
|
|
56
|
+
};
|
|
57
|
+
stores.set(key, record);
|
|
58
|
+
}
|
|
59
|
+
return record;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
interface ReadySyncProviderProps {
|
|
21
63
|
readonly client: SyncClientLike;
|
|
22
64
|
readonly children?: ReactNode;
|
|
23
65
|
}
|
|
24
66
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
const normalized =
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
67
|
+
function ReadySyncProvider(props: ReadySyncProviderProps): ReactNode {
|
|
68
|
+
const record = useMemo(() => recordFor(props.client), [props.client]);
|
|
69
|
+
const { normalized, store } = record;
|
|
70
|
+
useEffect(() => {
|
|
71
|
+
record.refs += 1;
|
|
72
|
+
store.start();
|
|
73
|
+
return () => {
|
|
74
|
+
record.refs -= 1;
|
|
75
|
+
queueMicrotask(() => {
|
|
76
|
+
if (record.refs === 0) store.dispose();
|
|
77
|
+
});
|
|
78
|
+
};
|
|
79
|
+
}, [record, store]);
|
|
31
80
|
return createElement(
|
|
32
81
|
SyncContext.Provider,
|
|
33
82
|
{ value: normalized },
|
|
83
|
+
createElement(SyncStoreContext.Provider, { value: store }, props.children),
|
|
84
|
+
);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const noSubscribe = (): (() => void) => () => {};
|
|
88
|
+
|
|
89
|
+
export function SyncProvider(props: SyncProviderProps): ReactNode {
|
|
90
|
+
const resource = isSyncClientResource(props.client)
|
|
91
|
+
? props.client
|
|
92
|
+
: undefined;
|
|
93
|
+
const readySnapshot = useMemo(
|
|
94
|
+
() =>
|
|
95
|
+
resource === undefined
|
|
96
|
+
? ({ phase: 'ready', client: props.client as SyncClientLike } as const)
|
|
97
|
+
: undefined,
|
|
98
|
+
[props.client, resource],
|
|
99
|
+
);
|
|
100
|
+
const snapshot = useSyncExternalStore(
|
|
101
|
+
resource?.subscribe ?? noSubscribe,
|
|
102
|
+
resource?.getSnapshot ??
|
|
103
|
+
(() => readySnapshot as NonNullable<typeof readySnapshot>),
|
|
104
|
+
resource?.getSnapshot ??
|
|
105
|
+
(() => readySnapshot as NonNullable<typeof readySnapshot>),
|
|
106
|
+
);
|
|
107
|
+
if (snapshot.phase === 'pending') return props.fallback ?? null;
|
|
108
|
+
if (snapshot.phase === 'error') {
|
|
109
|
+
if (props.renderError !== undefined)
|
|
110
|
+
return props.renderError(snapshot.error);
|
|
111
|
+
throw snapshot.error;
|
|
112
|
+
}
|
|
113
|
+
return createElement(
|
|
114
|
+
ReadySyncProvider,
|
|
115
|
+
{ client: snapshot.client },
|
|
34
116
|
props.children,
|
|
35
117
|
);
|
|
36
118
|
}
|
package/src/resource.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import type { SyncClientLike } from './client';
|
|
2
|
+
|
|
3
|
+
export type SyncClientResourceSnapshot =
|
|
4
|
+
| { readonly phase: 'pending' }
|
|
5
|
+
| { readonly phase: 'ready'; readonly client: SyncClientLike }
|
|
6
|
+
| { readonly phase: 'error'; readonly error: Error };
|
|
7
|
+
|
|
8
|
+
export interface SyncClientResource {
|
|
9
|
+
readonly kind: 'syncular-client-resource';
|
|
10
|
+
subscribe(listener: () => void): () => void;
|
|
11
|
+
getSnapshot(): SyncClientResourceSnapshot;
|
|
12
|
+
dispose(): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function createSyncClientResource(
|
|
16
|
+
factory: () => SyncClientLike | Promise<SyncClientLike>,
|
|
17
|
+
): SyncClientResource {
|
|
18
|
+
const listeners = new Set<() => void>();
|
|
19
|
+
let snapshot: SyncClientResourceSnapshot = { phase: 'pending' };
|
|
20
|
+
let disposed = false;
|
|
21
|
+
let closed = false;
|
|
22
|
+
const initialized = Promise.resolve()
|
|
23
|
+
.then(factory)
|
|
24
|
+
.then(
|
|
25
|
+
async (client) => {
|
|
26
|
+
if (disposed) {
|
|
27
|
+
const close = (client as { close?: () => void | Promise<void> })
|
|
28
|
+
.close;
|
|
29
|
+
if (close !== undefined && !closed) {
|
|
30
|
+
closed = true;
|
|
31
|
+
await close.call(client);
|
|
32
|
+
}
|
|
33
|
+
return;
|
|
34
|
+
}
|
|
35
|
+
snapshot = { phase: 'ready', client };
|
|
36
|
+
for (const listener of listeners) listener();
|
|
37
|
+
},
|
|
38
|
+
(error: unknown) => {
|
|
39
|
+
if (disposed) return;
|
|
40
|
+
snapshot = {
|
|
41
|
+
phase: 'error',
|
|
42
|
+
error: error instanceof Error ? error : new Error(String(error)),
|
|
43
|
+
};
|
|
44
|
+
for (const listener of listeners) listener();
|
|
45
|
+
},
|
|
46
|
+
);
|
|
47
|
+
|
|
48
|
+
return {
|
|
49
|
+
kind: 'syncular-client-resource',
|
|
50
|
+
subscribe(listener) {
|
|
51
|
+
listeners.add(listener);
|
|
52
|
+
return () => listeners.delete(listener);
|
|
53
|
+
},
|
|
54
|
+
getSnapshot: () => snapshot,
|
|
55
|
+
async dispose() {
|
|
56
|
+
if (disposed) return;
|
|
57
|
+
disposed = true;
|
|
58
|
+
await initialized;
|
|
59
|
+
if (snapshot.phase === 'ready' && !closed) {
|
|
60
|
+
const close = (
|
|
61
|
+
snapshot.client as { close?: () => void | Promise<void> }
|
|
62
|
+
).close;
|
|
63
|
+
if (close !== undefined) {
|
|
64
|
+
closed = true;
|
|
65
|
+
await close.call(snapshot.client);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
listeners.clear();
|
|
69
|
+
},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function isSyncClientResource(
|
|
74
|
+
value: SyncClientLike | SyncClientResource,
|
|
75
|
+
): value is SyncClientResource {
|
|
76
|
+
return (value as { kind?: unknown }).kind === 'syncular-client-resource';
|
|
77
|
+
}
|
package/src/use-client.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
+
import type { ReactiveClientStore } from '@syncular/client';
|
|
1
2
|
import { useContext } from 'react';
|
|
2
3
|
import type { NormalizedClient } from './client';
|
|
3
|
-
import { SyncContext } from './provider';
|
|
4
|
+
import { SyncContext, SyncStoreContext } from './provider';
|
|
4
5
|
|
|
5
6
|
/** Read the normalized client from context; throws outside a `SyncProvider`. */
|
|
6
7
|
export function useSyncClient(): NormalizedClient {
|
|
@@ -12,3 +13,13 @@ export function useSyncClient(): NormalizedClient {
|
|
|
12
13
|
}
|
|
13
14
|
return client;
|
|
14
15
|
}
|
|
16
|
+
|
|
17
|
+
export function useReactiveStore(): ReactiveClientStore {
|
|
18
|
+
const store = useContext(SyncStoreContext);
|
|
19
|
+
if (store === undefined) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
'@syncular/react: no client in context (reactive store unavailable) — wrap your tree in <SyncProvider client={…}>',
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
return store;
|
|
25
|
+
}
|
package/src/use-conflicts.ts
CHANGED
|
@@ -1,47 +1,28 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* `useConflicts` — the accumulated conflict records (§6.2/§6.5) and
|
|
3
|
-
* rejections (§6.3) the client has surfaced. Re-read after every apply
|
|
4
|
-
* batch (a push result lands through the same choke point) plus on mount.
|
|
5
|
-
*/
|
|
6
|
-
|
|
7
1
|
import type { ConflictRecord, RejectionRecord } from '@syncular/client';
|
|
8
|
-
import { useCallback,
|
|
9
|
-
import {
|
|
2
|
+
import { useCallback, useSyncExternalStore } from 'react';
|
|
3
|
+
import { useReactiveStore } from './use-client';
|
|
10
4
|
|
|
11
5
|
export interface UseConflictsResult {
|
|
12
6
|
readonly conflicts: readonly ConflictRecord[];
|
|
13
7
|
readonly rejections: readonly RejectionRecord[];
|
|
8
|
+
readonly isLoading: boolean;
|
|
9
|
+
readonly error: Error | undefined;
|
|
14
10
|
readonly refresh: () => void;
|
|
15
11
|
}
|
|
16
12
|
|
|
17
13
|
export function useConflicts(): UseConflictsResult {
|
|
18
|
-
const
|
|
19
|
-
const
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
})
|
|
33
|
-
.catch(() => {
|
|
34
|
-
/* transient read failure — the next batch re-reads */
|
|
35
|
-
});
|
|
36
|
-
};
|
|
37
|
-
readRef.current = read;
|
|
38
|
-
read();
|
|
39
|
-
const unsubscribe = client.onInvalidate(read);
|
|
40
|
-
return () => {
|
|
41
|
-
cancelled = true;
|
|
42
|
-
unsubscribe();
|
|
43
|
-
};
|
|
44
|
-
}, [client]);
|
|
45
|
-
|
|
46
|
-
return { conflicts, rejections, refresh };
|
|
14
|
+
const entry = useReactiveStore().conflicts;
|
|
15
|
+
const snapshot = useSyncExternalStore(
|
|
16
|
+
entry.subscribe,
|
|
17
|
+
entry.getSnapshot,
|
|
18
|
+
entry.getSnapshot,
|
|
19
|
+
);
|
|
20
|
+
const refresh = useCallback(() => entry.refresh(), [entry]);
|
|
21
|
+
return {
|
|
22
|
+
conflicts: snapshot.conflicts as readonly ConflictRecord[],
|
|
23
|
+
rejections: snapshot.rejections as readonly RejectionRecord[],
|
|
24
|
+
isLoading: snapshot.isLoading,
|
|
25
|
+
error: snapshot.error,
|
|
26
|
+
refresh,
|
|
27
|
+
};
|
|
47
28
|
}
|