@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.
@@ -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
- /** Set the live units (widen/shrink diff, §4.8). */
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;
@@ -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
- * `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).
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
- import { windowComplete, } from '@syncular/client';
23
- import { useCallback, useEffect, useRef, useState } from 'react';
24
- import { useSyncClient } from './use-client.js';
25
- const EMPTY = { units: [], pending: [] };
26
- export function useWindow(base) {
27
- const client = useSyncClient();
28
- const [state, setState] = useState(EMPTY);
29
- // A stable key so the effects re-run only when the base identity changes,
30
- // not on every render's fresh object. The latest `base` is read via a ref
31
- // inside the closures (the useRawSql pattern), so the dep list stays on
32
- // primitive keys.
33
- const baseKey = `${base.table} ${base.variable} ${JSON.stringify(base.fixedScopes ?? {})} ${base.params ?? ''}`;
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 cancelled = false;
41
- const read = () => {
42
- Promise.resolve(client.windowState(baseRef.current))
43
- .then((next) => {
44
- if (!cancelled)
45
- setState(next);
46
- })
47
- .catch(() => {
48
- /* transient the next invalidation re-reads */
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
- cancelled = true;
60
- unsubscribe();
39
+ active = false;
40
+ retention.release();
61
41
  };
62
- }, [client, baseKey]);
63
- const setWindow = useCallback((next) => {
64
- const result = Promise.resolve(client.setWindow(baseRef.current, next));
65
- // Optimistically reflect the new set; the invalidation-driven re-read
66
- // reconciles against the registry (e.g. a pinned unit lingering). The
67
- // optimistic update MUST NOT claim completeness: a unit stays (or
68
- // becomes) pending unless the previous snapshot already had it
69
- // complete entering units are mid-bootstrap until the re-read
70
- // confirms otherwise (§4.8: registration completeness).
71
- setState((prev) => ({
72
- units: next,
73
- pending: next.filter((unit) => !windowComplete(prev, unit)),
74
- }));
75
- return result;
76
- }, [client]);
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 { units: state.units, pending: state.pending, setWindow, isComplete };
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.4.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.3.1"
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.3.1",
59
- "@syncular/server": "0.3.1",
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 { SyncContext, SyncProvider, type SyncProviderProps } from './provider';
20
- export { useSyncClient } from './use-client';
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 { type UseMutationResult, useMutation } from './use-mutation';
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 { type UseWindowResult, useWindow } from './use-window';
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 { createContext, createElement, type ReactNode, useMemo } from 'react';
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
- export function SyncProvider(props: SyncProviderProps): ReactNode {
26
- // Re-normalize only when the client identity changes.
27
- const normalized = useMemo(
28
- () => normalizeClient(props.client),
29
- [props.client],
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
  }
@@ -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
+ }
@@ -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, useEffect, useRef, useState } from 'react';
9
- import { useSyncClient } from './use-client';
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 client = useSyncClient();
19
- const [conflicts, setConflicts] = useState<readonly ConflictRecord[]>([]);
20
- const [rejections, setRejections] = useState<readonly RejectionRecord[]>([]);
21
- const readRef = useRef<() => void>(() => {});
22
- const refresh = useCallback(() => readRef.current(), []);
23
-
24
- useEffect(() => {
25
- let cancelled = false;
26
- const read = () => {
27
- Promise.all([client.conflicts(), client.rejections()])
28
- .then(([c, r]) => {
29
- if (cancelled) return;
30
- setConflicts(c);
31
- setRejections(r);
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
  }