@syncular/react 0.4.1 → 0.5.1

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 CHANGED
@@ -1,154 +1,171 @@
1
1
  # @syncular/react
2
2
 
3
- React bindings for the syncular client, with **fine-grained live
4
- queries** designed in from day one (TODO 3.1 / `DESIGN-eviction.md` I1–I4).
5
- A `useRawSql` re-runs **only** when a table it depends on is touched by an
6
- apply batch — never "re-run everything on any change".
3
+ React 18+ bindings for Syncular's revisioned reactive store. The same hooks
4
+ work with the direct TypeScript client, the browser worker handle, and the
5
+ Tauri/React Native bridges.
7
6
 
8
- Works against **both** client cores through one interface:
7
+ The store is client-scoped, not hook-scoped. Equal queries share one local SQL
8
+ read per revision, rows and window completeness come from one SQLite snapshot,
9
+ and stale promises cannot overwrite a newer revision. React is only a
10
+ `useSyncExternalStore` adapter over that renderer-independent state.
9
11
 
10
- - `SyncClient` the direct core (constructed on the current thread), and
11
- - `SyncClientHandle` — the worker-mode proxy (the whole core in an OPFS
12
- worker).
12
+ ## Recommended query and mutation path
13
13
 
14
- Their public surfaces diverge (getters vs methods, sync vs promise); the
15
- bindings normalize both, so a component never cares which it holds.
16
-
17
- React 18+ is a **peer dependency**. There are no other runtime dependencies.
18
-
19
- ## Quick start
14
+ Author reads in `queries/*.sql` or `queries/*.syql`, run `syncular generate`,
15
+ and pass the generated descriptor to `useQuery`:
20
16
 
21
17
  ```tsx
22
- import { SyncProvider, useRawSql, useMutation } from '@syncular/react';
23
-
24
- // `client` is a SyncClient or a SyncClientHandle you already started.
25
- function App({ client }) {
26
- return (
27
- <SyncProvider client={client}>
28
- <Tasks />
29
- </SyncProvider>
30
- );
31
- }
18
+ import { SyncProvider, useMutation, useQuery } from '@syncular/react';
19
+ import { tasksTable } from './syncular.generated';
20
+ import { listTasksQuery } from './syncular.queries';
32
21
 
33
- function Tasks() {
34
- const { rows, isLoading, error, refresh } = useRawSql(
35
- 'SELECT id, title, done FROM tasks ORDER BY id',
36
- );
37
- const { mutate } = useMutation();
22
+ function Tasks({ projectId }: { projectId: string }) {
23
+ const tasks = useQuery(listTasksQuery, { projectId });
24
+ const mutation = useMutation(tasksTable);
38
25
 
39
- if (isLoading) return <p>Loading…</p>;
40
- if (error) return <p>Query failed: {error.message}</p>;
26
+ if (tasks.phase === 'loading') return <p>Loading…</p>;
27
+ if (tasks.phase === 'error') return <p>{tasks.error?.message}</p>;
28
+ if (tasks.phase === 'ready' && tasks.rows.length === 0) return <p>Empty</p>;
41
29
 
42
30
  return (
43
31
  <ul>
44
- {rows.map((r) => (
45
- <li key={r.id}>{r.title}</li>
32
+ {tasks.rows.map((task) => (
33
+ <li key={task.id}>
34
+ <button onClick={() => mutation.patch(task.id, { done: !task.done })}>
35
+ {task.title}
36
+ </button>
37
+ </li>
46
38
  ))}
47
- <button
48
- onClick={() =>
49
- mutate([
50
- {
51
- table: 'tasks',
52
- op: 'upsert',
53
- values: { id: crypto.randomUUID(), project_id: 'p1', title: 'new', done: false },
54
- },
55
- ])
56
- }
57
- >
58
- Add
59
- </button>
60
39
  </ul>
61
40
  );
62
41
  }
63
42
  ```
64
43
 
65
- The mutate applies optimistically (§7.1) and fires the invalidation batch, so
66
- the list updates immediately — no manual refetch.
44
+ Typegen puts these facts on the descriptor:
67
45
 
68
- ## The invalidation granularity truth
46
+ - a QueryIR-derived cache id, so SQL-only edits cannot reuse old state;
47
+ - exact table/scope dependencies for change routing;
48
+ - provable window coverage, claimed automatically while observed;
49
+ - a stable row key when the projection proves one.
69
50
 
70
- This is the honest granularity the wire actually provides — read it before
71
- relying on scope-key narrowing.
51
+ `useQuery` returns `{ rows, phase, revision, isLoading, isRefreshing, error,
52
+ refresh }`. `phase` is:
72
53
 
73
- The web-client emits **exactly one** `{ tables, scopeKeys }` invalidation event
74
- per apply batch (a pull/delta round, a local `mutate`, a purge, or a
75
- schema-bump reset the ONE choke point). Never one event per row.
54
+ - `loading`: there is not yet a complete answer and there are no partial rows;
55
+ - `partial`: rows exist, but some required window coverage is incomplete;
56
+ - `ready`: the atomic snapshot says the answer is complete, including an
57
+ honestly empty result;
58
+ - `error`: the initial read failed. A later refresh error keeps existing rows
59
+ and phase visible through `error`/`isRefreshing`.
76
60
 
77
- - **`tables`** the set of tables whose local rows changed this batch. This
78
- is the **reliable floor**: it is always present and always correct.
79
- - **`scopeKeys`** — `prefix:value` scope keys (§3.1), present **where the
80
- source carried them**:
81
- - `COMMIT` frames carry per-row stored scopes (§4.5), so commit-driven
82
- invalidation carries **precise** scope keys.
83
- - **Segments carry no per-row scope keys** — only a table + a scope digest.
84
- A segment (bootstrap / re-bootstrap) invalidation therefore carries the
85
- table plus the **subscription's effective scope keys**, the coarsest
86
- honest key for bulk data. It never fabricates per-row keys the wire did
87
- not deliver.
88
- - Purge / reset / optimistic writes are keyed by table (and by effective
89
- scope keys where a scope map is in hand).
61
+ ## Provider and async initialization
90
62
 
91
- **Consequence for `useRawSql`:** by default a query re-runs whenever a
92
- depended-on **table** is touched. You may narrow further with `scopeKeys`
93
- (below), but a **table-level** event (a segment bootstrap, a reset — one that
94
- carries no scope keys) **always** re-runs a matching query, because it carries
95
- no key to discriminate on. This is deliberate: under-running is a stale query,
96
- the one thing a live-query layer must never do.
63
+ A ready client can be passed directly:
97
64
 
98
- ## `useRawSql(sql, params?, options?)`
65
+ ```tsx
66
+ <SyncProvider client={client}><Tasks projectId="p1" /></SyncProvider>
67
+ ```
99
68
 
100
- Runs a local SQL query and keeps it live.
69
+ For async engines, create one resource outside React render. It owns one
70
+ initialization attempt across StrictMode remounts and closes the client exactly
71
+ once when explicitly disposed:
101
72
 
102
- Returns `{ rows, isLoading, error, refresh }`.
73
+ ```tsx
74
+ import { createSyncClientResource, SyncProvider } from '@syncular/react';
103
75
 
104
- ### Dependency tables inference and the escape hatch
76
+ const clientResource = createSyncClientResource(() => createClient());
105
77
 
106
- By default the hook infers its dependency tables with a **conservative scan**
107
- of the SQL text (the identifiers after `FROM`/`JOIN`). This is a heuristic,
108
- **not** a SQL parser — it is intentionally over-inclusive at the edges (an
109
- extra harmless re-run) rather than under-inclusive (a stale query).
78
+ <SyncProvider
79
+ client={clientResource}
80
+ fallback={<p>Starting local database…</p>}
81
+ renderError={(error) => <p>{error.message}</p>}
82
+ >
83
+ <App />
84
+ </SyncProvider>
85
+ ```
86
+
87
+ Call `await clientResource.dispose()` from the application's real lifecycle
88
+ owner, not from a StrictMode-sensitive child effect.
89
+
90
+ ## `useMutation`
110
91
 
111
- When the text cannot be read (dynamic SQL, views, unusual syntax), pass the
112
- explicit **`tables`** option it always wins:
92
+ `useMutation()` retains the raw batch API. `useMutation(generatedTable)` adds
93
+ typed `upsert`, `patch`, and `remove` helpers:
113
94
 
114
95
  ```tsx
115
- useRawSql(buildDynamicSql(), params, { tables: ['tasks', 'projects'] });
96
+ const mutation = useMutation(tasksTable, {
97
+ onSuccess(commitId) {},
98
+ onError(error) {},
99
+ });
100
+
101
+ await mutation.upsert({ id, projectId, title, done: false });
102
+ await mutation.patch(id, { title: 'Renamed' });
103
+ await mutation.remove(id);
116
104
  ```
117
105
 
118
- ### Options
106
+ It returns `pendingCount`, `isPending`, `error`, and `resetError`. Overlapping
107
+ writes remain pending until all calls settle. Every method still returns a
108
+ promise and rejects on failure; callbacks do not replace error handling.
119
109
 
120
- | Option | Meaning |
121
- | ----------- | --------------------------------------------------------------------------------------------------------------------------------- |
122
- | `tables` | Explicit dependency tables. Overrides the SQL-text inference (the escape hatch). |
123
- | `scopeKeys` | Narrow re-runs to specific `prefix:value` keys. A dependency-table event still re-runs if it carries **no** scope keys (see above). |
124
- | `enabled` | Skip running while `false` (e.g. inputs not ready). |
110
+ ## `useRawSql`
111
+
112
+ `useRawSql(sql, params?, options?)` is the read-only escape hatch for dynamic
113
+ SQL. It returns the same phase/revision result as `useQuery`.
114
+
115
+ ```tsx
116
+ const summary = useRawSql<{ total: number }>(
117
+ 'SELECT count(*) AS total FROM tasks WHERE project_id = ?',
118
+ [projectId],
119
+ {
120
+ dependencies: [{ table: 'tasks', scopeKeys: [`project:${projectId}`] }],
121
+ },
122
+ );
123
+ ```
124
+
125
+ Options:
126
+
127
+ | Option | Meaning |
128
+ | --- | --- |
129
+ | `dependencies` | Exact table-associated dependencies. |
130
+ | `coverage` | Required window units read atomically with the rows. |
131
+ | `rowKey` | Stable identity fields used to retain unchanged row objects. |
132
+ | `claimCoverage` | Claim declared coverage while observed; default `true`. |
133
+ | `enabled` | Disable observation and reads while `false`. |
134
+ | `id` | Stable cache identity for a raw query. |
135
+ | `tables`, `scopeKeys` | Compatibility shorthand; prefer `dependencies`. |
136
+
137
+ Without explicit dependencies, raw SQL uses a conservative `FROM`/`JOIN`
138
+ scan. Generated queries are preferred whenever the statement is known at
139
+ build time because typegen can prove more.
140
+
141
+ ## Exact changes and windows
142
+
143
+ Both cores emit one revisioned `ClientChangeBatch` per observer transaction.
144
+ Table changes keep scope keys associated with their table; window changes can
145
+ complete a zero-row unit without inventing a row change; status/conflict-only
146
+ batches do not rerun SQL. Bridges forward this batch unchanged.
147
+
148
+ Generated query coverage uses composable claims. Multiple consumers of the
149
+ same window base contribute a union, and unmounting one removes only its own
150
+ units. `useWindow(base)` remains the lower-level imperative interface for
151
+ prefetching or dynamic query builders. Applications normally do not need it
152
+ beside a generated `useQuery`.
153
+
154
+ For a small known navigation working set,
155
+ `useRetainedWindow(base, units)` prefetches and retains those units through the
156
+ same coordinator. It returns `{ isPending, error }`, normalizes duplicate
157
+ units, and releases only its own claim on unmount, so application code does
158
+ not need a custom retention effect.
125
159
 
126
160
  ## Other hooks
127
161
 
128
- - **`useSyncStatus()`** `{ outbox, upgrading, leaseState, schemaFloor,
129
- syncNeeded, isLoading, refresh }`. Re-reads after every apply batch.
130
- (`online` is not a value the core exposes — §1.3 transport-owned — so it is
131
- not reported rather than guessed.)
132
- - **`useConflicts()`** → `{ conflicts, rejections, refresh }` (§6.2/§6.3).
133
- - **`usePresence(scopeKey)`** the ephemeral peers present on a §8.6 scope
134
- key; updates on join/update/leave. Empty (and never crashes) without a
135
- connected realtime socket.
136
- - **`useMutation()`** → `{ mutate, isPending, error }`. `mutate(mutations)`
137
- resolves to the `clientCommitId`; the optimistic overlay is applied
138
- immediately, and dependent `useRawSql`s re-run on the resulting batch.
139
-
140
- ## SSR
141
-
142
- The hooks are SSR-safe: on the server they render their initial state and the
143
- query fires only in the client-side mount effect. `renderToString` never
144
- crashes.
145
-
146
- ## Design note — the window registry (forward-looking)
147
-
148
- Per `DESIGN-eviction.md` I3, query bindings must be able to route a query's
149
- scope footprint through the window registry once windowed sync (TODO §5
150
- item 2) lands, so a query can report **completeness** (answerable from the
151
- local replica vs a window miss). Today the registry trivially contains
152
- "everything subscribed", so `useRawSql` always answers from the local
153
- replica. The `scopeKeys` option is the seam through which per-scope
154
- completeness will be surfaced without an API break.
162
+ - `useSyncStatus()` observes the status domain without a follow-up read after
163
+ every row change. `outbox` is local push work; `syncNeeded` specifically
164
+ means an inbound pull/catch-up signal.
165
+ - `useConflicts()` observes conflicts and rejections only when that domain
166
+ changes.
167
+ - `usePresence(scopeKey)` observes ephemeral realtime peers.
168
+ - `useSyncClient()` and `useReactiveStore()` expose the normalized low-level
169
+ surfaces for integrations.
170
+
171
+ The hooks are SSR-safe: no local query runs during server rendering.
package/dist/client.d.ts CHANGED
@@ -11,7 +11,7 @@
11
11
  * value → read it) and wraps every result in `Promise.resolve`, so a hook
12
12
  * never has to care which core it holds.
13
13
  */
14
- import type { ConflictRecord, InvalidationListener, LeaseState, MutationInput, PresencePeer, RejectionRecord, SchemaFloor, SqlRow, SqlValue, WindowBase, WindowState } from '@syncular/client';
14
+ import type { ClientChangeListener, ConflictRecord, InvalidationListener, LeaseState, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, SchemaFloor, SqlRow, SqlValue, SyncStatusSnapshot, WindowBase, WindowState } from '@syncular/client';
15
15
  /**
16
16
  * The structural union of `SyncClient` and `SyncClientHandle`. Members that
17
17
  * diverge are typed as "value or method, sync or promise"; {@link normalizeClient}
@@ -19,10 +19,16 @@ import type { ConflictRecord, InvalidationListener, LeaseState, MutationInput, P
19
19
  * never reach past this surface.
20
20
  */
21
21
  export interface SyncClientLike {
22
+ onChange(listener: ClientChangeListener): () => void;
22
23
  onInvalidate(listener: InvalidationListener): () => void;
23
24
  onPresence(listener: (scopeKey: string) => void): () => void;
24
25
  query(sql: string, params?: readonly SqlValue[]): SqlRow[] | Promise<SqlRow[]>;
25
26
  mutate(mutations: readonly MutationInput[]): string | Promise<string>;
27
+ patch(table: string, rowId: string, partial: Readonly<Record<string, unknown>>, options?: {
28
+ readonly baseVersion?: number;
29
+ }): string | Promise<string>;
30
+ querySnapshot<Row = SqlRow>(spec: QueryReadSpec): QuerySnapshot<Row> | Promise<QuerySnapshot<Row>>;
31
+ statusSnapshot(): SyncStatusSnapshot | Promise<SyncStatusSnapshot>;
26
32
  conflicts: readonly ConflictRecord[] | (() => readonly ConflictRecord[] | Promise<readonly ConflictRecord[]>);
27
33
  rejections: readonly RejectionRecord[] | (() => readonly RejectionRecord[] | Promise<readonly RejectionRecord[]>);
28
34
  schemaFloor: SchemaFloor | undefined | (() => SchemaFloor | undefined | Promise<SchemaFloor | undefined>);
@@ -39,10 +45,16 @@ export interface SyncClientLike {
39
45
  }
40
46
  /** The uniform async facade the hooks actually call. */
41
47
  export interface NormalizedClient {
48
+ onChange(listener: ClientChangeListener): () => void;
42
49
  onInvalidate(listener: InvalidationListener): () => void;
43
50
  onPresence(listener: (scopeKey: string) => void): () => void;
44
51
  query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
45
52
  mutate(mutations: readonly MutationInput[]): Promise<string>;
53
+ patch(table: string, rowId: string, partial: Readonly<Record<string, unknown>>, options?: {
54
+ readonly baseVersion?: number;
55
+ }): Promise<string>;
56
+ querySnapshot<Row = SqlRow>(spec: QueryReadSpec): Promise<QuerySnapshot<Row>>;
57
+ statusSnapshot(): Promise<SyncStatusSnapshot>;
46
58
  conflicts(): Promise<readonly ConflictRecord[]>;
47
59
  rejections(): Promise<readonly RejectionRecord[]>;
48
60
  schemaFloor(): Promise<SchemaFloor | undefined>;
package/dist/client.js CHANGED
@@ -12,10 +12,14 @@ function resolveMember(client, key) {
12
12
  }
13
13
  export function normalizeClient(client) {
14
14
  return {
15
+ onChange: (listener) => client.onChange(listener),
15
16
  onInvalidate: (listener) => client.onInvalidate(listener),
16
17
  onPresence: (listener) => client.onPresence(listener),
17
18
  query: (sql, params) => Promise.resolve(client.query(sql, params)),
18
19
  mutate: (mutations) => Promise.resolve(client.mutate(mutations)),
20
+ patch: (table, rowId, partial, options) => Promise.resolve(client.patch(table, rowId, partial, options)),
21
+ querySnapshot: (spec) => Promise.resolve(client.querySnapshot(spec)),
22
+ statusSnapshot: () => Promise.resolve(client.statusSnapshot()),
19
23
  conflicts: () => resolveMember(client, 'conflicts'),
20
24
  rejections: () => resolveMember(client, 'rejections'),
21
25
  schemaFloor: () => resolveMember(client, 'schemaFloor'),
package/dist/index.d.ts CHANGED
@@ -10,12 +10,13 @@
10
10
  export type { NormalizedClient, SyncClientLike, } from './client.js';
11
11
  export { normalizeClient } from './client.js';
12
12
  export { inferTables } from './infer-tables.js';
13
- export { SyncContext, SyncProvider, type SyncProviderProps } from './provider.js';
14
- export { useSyncClient } from './use-client.js';
13
+ export { SyncContext, SyncProvider, type SyncProviderProps, SyncStoreContext, } from './provider.js';
14
+ export { createSyncClientResource, isSyncClientResource, type SyncClientResource, type SyncClientResourceSnapshot, } from './resource.js';
15
+ export { useReactiveStore, useSyncClient } from './use-client.js';
15
16
  export { type UseConflictsResult, useConflicts } from './use-conflicts.js';
16
- export { type UseMutationResult, useMutation } from './use-mutation.js';
17
+ export { type SyncTableDescriptor, type UseMutationOptions, type UseMutationResult, type UseTableMutationResult, useMutation, } from './use-mutation.js';
17
18
  export { usePresence } from './use-presence.js';
18
19
  export { type NamedQueryDescriptor, useQuery, } from './use-query.js';
19
20
  export { type UseRawSqlOptions, type UseRawSqlResult, useRawSql, } from './use-raw-sql.js';
20
21
  export { type SyncStatus, useSyncStatus } from './use-sync-status.js';
21
- export { type UseWindowResult, useWindow } from './use-window.js';
22
+ export { type UseRetainedWindowResult, type UseWindowResult, useRetainedWindow, useWindow, } from './use-window.js';
package/dist/index.js CHANGED
@@ -3,12 +3,13 @@
3
3
  // the exact normalizer the bindings use.
4
4
  export { normalizeClient } from './client.js';
5
5
  export { inferTables } from './infer-tables.js';
6
- export { SyncContext, SyncProvider } from './provider.js';
7
- export { useSyncClient } from './use-client.js';
6
+ export { SyncContext, SyncProvider, SyncStoreContext, } from './provider.js';
7
+ export { createSyncClientResource, isSyncClientResource, } from './resource.js';
8
+ export { useReactiveStore, useSyncClient } from './use-client.js';
8
9
  export { useConflicts } from './use-conflicts.js';
9
- export { useMutation } from './use-mutation.js';
10
+ export { useMutation, } from './use-mutation.js';
10
11
  export { usePresence } from './use-presence.js';
11
12
  export { useQuery, } from './use-query.js';
12
13
  export { useRawSql, } from './use-raw-sql.js';
13
14
  export { useSyncStatus } from './use-sync-status.js';
14
- export { useWindow } from './use-window.js';
15
+ export { useRetainedWindow, useWindow, } from './use-window.js';
@@ -5,12 +5,17 @@
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 { ReactiveClientStore } from '@syncular/client';
8
9
  import { type ReactNode } from 'react';
9
10
  import { type NormalizedClient, type SyncClientLike } from './client.js';
11
+ import { type SyncClientResource } from './resource.js';
10
12
  export declare const SyncContext: import("react").Context<NormalizedClient | undefined>;
13
+ export declare const SyncStoreContext: import("react").Context<ReactiveClientStore | undefined>;
11
14
  export interface SyncProviderProps {
12
15
  /** A `SyncClient` (direct) or `SyncClientHandle` (worker) — both satisfy it. */
13
- readonly client: SyncClientLike;
16
+ readonly client: SyncClientLike | SyncClientResource;
14
17
  readonly children?: ReactNode;
18
+ readonly fallback?: ReactNode;
19
+ readonly renderError?: (error: Error) => ReactNode;
15
20
  }
16
21
  export declare function SyncProvider(props: SyncProviderProps): ReactNode;
package/dist/provider.js CHANGED
@@ -5,11 +5,60 @@
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, useMemo } from 'react';
8
+ import { ReactiveClientStore } from '@syncular/client';
9
+ import { createContext, createElement, useEffect, useMemo, useSyncExternalStore, } from 'react';
9
10
  import { normalizeClient, } from './client.js';
11
+ import { isSyncClientResource } from './resource.js';
10
12
  export const SyncContext = createContext(undefined);
13
+ export const SyncStoreContext = createContext(undefined);
14
+ const stores = new WeakMap();
15
+ function recordFor(client) {
16
+ const key = client;
17
+ let record = stores.get(key);
18
+ if (record === undefined) {
19
+ const normalized = normalizeClient(client);
20
+ record = {
21
+ normalized,
22
+ store: new ReactiveClientStore(normalized),
23
+ refs: 0,
24
+ };
25
+ stores.set(key, record);
26
+ }
27
+ return record;
28
+ }
29
+ function ReadySyncProvider(props) {
30
+ const record = useMemo(() => recordFor(props.client), [props.client]);
31
+ const { normalized, store } = record;
32
+ useEffect(() => {
33
+ record.refs += 1;
34
+ store.start();
35
+ return () => {
36
+ record.refs -= 1;
37
+ queueMicrotask(() => {
38
+ if (record.refs === 0)
39
+ store.dispose();
40
+ });
41
+ };
42
+ }, [record, store]);
43
+ return createElement(SyncContext.Provider, { value: normalized }, createElement(SyncStoreContext.Provider, { value: store }, props.children));
44
+ }
45
+ const noSubscribe = () => () => { };
11
46
  export function SyncProvider(props) {
12
- // Re-normalize only when the client identity changes.
13
- const normalized = useMemo(() => normalizeClient(props.client), [props.client]);
14
- return createElement(SyncContext.Provider, { value: normalized }, props.children);
47
+ const resource = isSyncClientResource(props.client)
48
+ ? props.client
49
+ : undefined;
50
+ const readySnapshot = useMemo(() => resource === undefined
51
+ ? { phase: 'ready', client: props.client }
52
+ : undefined, [props.client, resource]);
53
+ const snapshot = useSyncExternalStore(resource?.subscribe ?? noSubscribe, resource?.getSnapshot ??
54
+ (() => readySnapshot), resource?.getSnapshot ??
55
+ (() => readySnapshot));
56
+ if (snapshot.phase === 'pending')
57
+ return props.fallback ?? null;
58
+ if (snapshot.phase === 'error') {
59
+ if (props.renderError !== undefined)
60
+ return props.renderError(snapshot.error);
61
+ throw snapshot.error;
62
+ }
63
+ return createElement(ReadySyncProvider, { client: snapshot.client }, props.children);
15
64
  }
@@ -0,0 +1,18 @@
1
+ import type { SyncClientLike } from './client.js';
2
+ export type SyncClientResourceSnapshot = {
3
+ readonly phase: 'pending';
4
+ } | {
5
+ readonly phase: 'ready';
6
+ readonly client: SyncClientLike;
7
+ } | {
8
+ readonly phase: 'error';
9
+ readonly error: Error;
10
+ };
11
+ export interface SyncClientResource {
12
+ readonly kind: 'syncular-client-resource';
13
+ subscribe(listener: () => void): () => void;
14
+ getSnapshot(): SyncClientResourceSnapshot;
15
+ dispose(): Promise<void>;
16
+ }
17
+ export declare function createSyncClientResource(factory: () => SyncClientLike | Promise<SyncClientLike>): SyncClientResource;
18
+ export declare function isSyncClientResource(value: SyncClientLike | SyncClientResource): value is SyncClientResource;
@@ -0,0 +1,56 @@
1
+ export function createSyncClientResource(factory) {
2
+ const listeners = new Set();
3
+ let snapshot = { phase: 'pending' };
4
+ let disposed = false;
5
+ let closed = false;
6
+ const initialized = Promise.resolve()
7
+ .then(factory)
8
+ .then(async (client) => {
9
+ if (disposed) {
10
+ const close = client
11
+ .close;
12
+ if (close !== undefined && !closed) {
13
+ closed = true;
14
+ await close.call(client);
15
+ }
16
+ return;
17
+ }
18
+ snapshot = { phase: 'ready', client };
19
+ for (const listener of listeners)
20
+ listener();
21
+ }, (error) => {
22
+ if (disposed)
23
+ return;
24
+ snapshot = {
25
+ phase: 'error',
26
+ error: error instanceof Error ? error : new Error(String(error)),
27
+ };
28
+ for (const listener of listeners)
29
+ listener();
30
+ });
31
+ return {
32
+ kind: 'syncular-client-resource',
33
+ subscribe(listener) {
34
+ listeners.add(listener);
35
+ return () => listeners.delete(listener);
36
+ },
37
+ getSnapshot: () => snapshot,
38
+ async dispose() {
39
+ if (disposed)
40
+ return;
41
+ disposed = true;
42
+ await initialized;
43
+ if (snapshot.phase === 'ready' && !closed) {
44
+ const close = snapshot.client.close;
45
+ if (close !== undefined) {
46
+ closed = true;
47
+ await close.call(snapshot.client);
48
+ }
49
+ }
50
+ listeners.clear();
51
+ },
52
+ };
53
+ }
54
+ export function isSyncClientResource(value) {
55
+ return value.kind === 'syncular-client-resource';
56
+ }
@@ -1,3 +1,5 @@
1
+ import type { ReactiveClientStore } from '@syncular/client';
1
2
  import type { NormalizedClient } from './client.js';
2
3
  /** Read the normalized client from context; throws outside a `SyncProvider`. */
3
4
  export declare function useSyncClient(): NormalizedClient;
5
+ export declare function useReactiveStore(): ReactiveClientStore;
@@ -1,5 +1,5 @@
1
1
  import { useContext } from 'react';
2
- import { SyncContext } from './provider.js';
2
+ import { SyncContext, SyncStoreContext } from './provider.js';
3
3
  /** Read the normalized client from context; throws outside a `SyncProvider`. */
4
4
  export function useSyncClient() {
5
5
  const client = useContext(SyncContext);
@@ -8,3 +8,10 @@ export function useSyncClient() {
8
8
  }
9
9
  return client;
10
10
  }
11
+ export function useReactiveStore() {
12
+ const store = useContext(SyncStoreContext);
13
+ if (store === undefined) {
14
+ throw new Error('@syncular/react: no client in context (reactive store unavailable) — wrap your tree in <SyncProvider client={…}>');
15
+ }
16
+ return store;
17
+ }
@@ -1,12 +1,9 @@
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
1
  import type { ConflictRecord, RejectionRecord } from '@syncular/client';
7
2
  export interface UseConflictsResult {
8
3
  readonly conflicts: readonly ConflictRecord[];
9
4
  readonly rejections: readonly RejectionRecord[];
5
+ readonly isLoading: boolean;
6
+ readonly error: Error | undefined;
10
7
  readonly refresh: () => void;
11
8
  }
12
9
  export declare function useConflicts(): UseConflictsResult;
@@ -1,37 +1,14 @@
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
- import { useCallback, useEffect, useRef, useState } from 'react';
7
- import { useSyncClient } from './use-client.js';
1
+ import { useCallback, useSyncExternalStore } from 'react';
2
+ import { useReactiveStore } from './use-client.js';
8
3
  export function useConflicts() {
9
- const client = useSyncClient();
10
- const [conflicts, setConflicts] = useState([]);
11
- const [rejections, setRejections] = useState([]);
12
- const readRef = useRef(() => { });
13
- const refresh = useCallback(() => readRef.current(), []);
14
- useEffect(() => {
15
- let cancelled = false;
16
- const read = () => {
17
- Promise.all([client.conflicts(), client.rejections()])
18
- .then(([c, r]) => {
19
- if (cancelled)
20
- return;
21
- setConflicts(c);
22
- setRejections(r);
23
- })
24
- .catch(() => {
25
- /* transient read failure — the next batch re-reads */
26
- });
27
- };
28
- readRef.current = read;
29
- read();
30
- const unsubscribe = client.onInvalidate(read);
31
- return () => {
32
- cancelled = true;
33
- unsubscribe();
34
- };
35
- }, [client]);
36
- return { conflicts, rejections, refresh };
4
+ const entry = useReactiveStore().conflicts;
5
+ const snapshot = useSyncExternalStore(entry.subscribe, entry.getSnapshot, entry.getSnapshot);
6
+ const refresh = useCallback(() => entry.refresh(), [entry]);
7
+ return {
8
+ conflicts: snapshot.conflicts,
9
+ rejections: snapshot.rejections,
10
+ isLoading: snapshot.isLoading,
11
+ error: snapshot.error,
12
+ refresh,
13
+ };
37
14
  }