@syncular/react 0.15.47 → 0.16.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
@@ -6,7 +6,10 @@ Tauri/React Native bridges.
6
6
 
7
7
  The store is client-scoped, not hook-scoped. Equal queries share one local SQL
8
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
9
+ and stale promises cannot overwrite a newer revision. Status, conflict, and
10
+ outcome refreshes discard older pending results after a newer read or event.
11
+ Presence reads follow the same ordering and clear peers when the client or
12
+ scope changes. React is only a
10
13
  `useSyncExternalStore` adapter over that renderer-independent state.
11
14
 
12
15
  ## Recommended query and mutation path
@@ -176,7 +179,7 @@ typed `upsert`, `patch`, and `remove` helpers:
176
179
 
177
180
  ```tsx
178
181
  const mutation = useMutation(tasksTable, {
179
- onSuccess(commitId) {},
182
+ onEnqueued(commitId) {},
180
183
  onError(error) {},
181
184
  });
182
185
 
@@ -322,3 +325,21 @@ The snapshot deliberately excludes scopes, rows, clinical counts, SQL, paths,
322
325
  identities, credentials, mutation bodies, stack traces, and arbitrary prose.
323
326
  Do not enrich the copied bundle with database files, query results, or console
324
327
  dumps. See SPEC §7.6 for the complete contract.
328
+
329
+ Query and window observers retain inactive entries only until the next
330
+ microtask. The final unsubscribe removes the entry from change dispatch
331
+ immediately; cleanup releases its rows and invalidates pending reads. A
332
+ same-microtask remount preserves the shared snapshot. A later subscription
333
+ starts a fresh read, including subscriptions held by an older React render.
334
+ Releasing the final window owner removes the empty claim group after the
335
+ core has applied the release. Disposing the store rejects unapplied retention
336
+ handles with `client.reactive_store_disposed`.
337
+
338
+ `SyncProvider` keeps the supplied client identity. `useSyncClient()` returns
339
+ that client, whose read methods can return either values or promises. Use
340
+ `await client.statusSnapshot()` in application code that supports multiple
341
+ hosts. The `schemaFloor`, `leaseState`, `upgrading`, and `syncNeeded` fields
342
+ come from that snapshot. Direct `conflicts`, `rejections`, and
343
+ `securityLifecycle` reads are now method calls. `normalizeClient` has been
344
+ removed; custom adapters must implement the canonical snapshot methods.
345
+ See the [client migration](https://syncular.dev/platform-web/#snapshot-api-migration).
package/dist/client.d.ts CHANGED
@@ -1,24 +1,6 @@
1
- /**
2
- * The ONE client interface the React bindings target. Both the direct
3
- * `SyncClient` (constructed on the current thread) and the worker-mode
4
- * `SyncClientHandle` (a promise proxy over the OPFS worker) satisfy it —
5
- * their public surfaces diverge (getters vs methods, sync vs promise), so
6
- * this module normalizes both into a single async-friendly facade the hooks
7
- * consume. That is the "one interface across direct and worker-handle modes"
8
- * the invalidation seam enables.
9
- *
10
- * The normalizer resolves each accessor at call time (function → call it,
11
- * value → read it) and wraps every result in `Promise.resolve`, so a hook
12
- * never has to care which core it holds.
13
- */
14
- import type { ClientChangeListener, ClientDiagnosticsListener, ClientDiagnosticsRequest, ClientDiagnosticsSnapshot, CommitOutcome, CommitOutcomeQuery, ConflictRecord, InvalidationListener, LeadershipState, LeaseState, LocalDataPurgeInput, LocalDataPurgeResult, LocalDataRebootstrapInput, LocalDataRebootstrapResult, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, RejectionRecord, ResolveCommitOutcomeInput, SchemaFloor, SecurityLifecycle, SqlRow, SqlValue, SyncStatusSnapshot, WindowBase, WindowState } from '@syncular/client';
15
- /**
16
- * The structural union of `SyncClient` and `SyncClientHandle`. Members that
17
- * diverge are typed as "value or method, sync or promise"; {@link normalizeClient}
18
- * collapses the divergence. Only what the hooks use is listed — the bindings
19
- * never reach past this surface.
20
- */
21
- export interface SyncClientLike {
1
+ /** The provider uses the supplied client identity and canonical snapshot methods. */
2
+ import type { ClientSnapshotReader, ClientChangeListener, ClientDiagnosticsListener, InvalidationListener, LeadershipState, LocalDataPurgeInput, LocalDataPurgeResult, LocalDataRebootstrapInput, LocalDataRebootstrapResult, MutationInput, PresencePeer, QueryReadSpec, QuerySnapshot, SecurityLifecycle, SqlRow, SqlValue, WindowBase, WindowState } from '@syncular/client';
3
+ export interface SyncClientLike extends ClientSnapshotReader {
22
4
  readonly currentSchemaVersion?: number;
23
5
  onChange(listener: ClientChangeListener): () => void;
24
6
  onDiagnostics(listener: ClientDiagnosticsListener): () => void;
@@ -26,7 +8,7 @@ export interface SyncClientLike {
26
8
  onPresence(listener: (scopeKey: string) => void): () => void;
27
9
  onLeadershipChange?(listener: (state: LeadershipState) => void): () => void;
28
10
  leadershipSnapshot?(): LeadershipState | undefined;
29
- securityLifecycle: SecurityLifecycle | (() => SecurityLifecycle | Promise<SecurityLifecycle>);
11
+ securityLifecycle(): SecurityLifecycle | Promise<SecurityLifecycle>;
30
12
  beginSecurityPreflight(): void | Promise<void>;
31
13
  /** Key-bearing activation remains available on each concrete host type. */
32
14
  activateSecurity(): void | Promise<void>;
@@ -38,17 +20,6 @@ export interface SyncClientLike {
38
20
  purgeLocalData(input: LocalDataPurgeInput): LocalDataPurgeResult | Promise<LocalDataPurgeResult>;
39
21
  rebootstrapLocalData(input: LocalDataRebootstrapInput): LocalDataRebootstrapResult | Promise<LocalDataRebootstrapResult>;
40
22
  querySnapshot<Row = SqlRow>(spec: QueryReadSpec): QuerySnapshot<Row> | Promise<QuerySnapshot<Row>>;
41
- statusSnapshot(): SyncStatusSnapshot | Promise<SyncStatusSnapshot>;
42
- diagnosticsSnapshot(request?: ClientDiagnosticsRequest): ClientDiagnosticsSnapshot | Promise<ClientDiagnosticsSnapshot>;
43
- conflicts: readonly ConflictRecord[] | (() => readonly ConflictRecord[] | Promise<readonly ConflictRecord[]>);
44
- rejections: readonly RejectionRecord[] | (() => readonly RejectionRecord[] | Promise<readonly RejectionRecord[]>);
45
- commitOutcome(clientCommitId: string): CommitOutcome | undefined | Promise<CommitOutcome | undefined>;
46
- commitOutcomes(query?: CommitOutcomeQuery): readonly CommitOutcome[] | Promise<readonly CommitOutcome[]>;
47
- resolveCommitOutcome(input: ResolveCommitOutcomeInput): CommitOutcome | Promise<CommitOutcome>;
48
- schemaFloor: SchemaFloor | undefined | (() => SchemaFloor | undefined | Promise<SchemaFloor | undefined>);
49
- leaseState: LeaseState | undefined | (() => LeaseState | undefined | Promise<LeaseState | undefined>);
50
- upgrading: boolean | (() => boolean | Promise<boolean>);
51
- syncNeeded: boolean | (() => boolean | Promise<boolean>);
52
23
  pendingCommits: () => unknown[] | Promise<unknown[]>;
53
24
  presence(scopeKey: string): readonly PresencePeer[] | Promise<readonly PresencePeer[]>;
54
25
  setPresence(scopeKey: string, doc: Record<string, unknown> | null): void | Promise<void>;
@@ -57,42 +28,3 @@ export interface SyncClientLike {
57
28
  /** §4.8 completeness oracle (I3): the windowed-in units for a base. */
58
29
  windowState(base: WindowBase): WindowState | Promise<WindowState>;
59
30
  }
60
- /** The uniform async facade the hooks actually call. */
61
- export interface NormalizedClient {
62
- readonly currentSchemaVersion?: number;
63
- onChange(listener: ClientChangeListener): () => void;
64
- onDiagnostics(listener: ClientDiagnosticsListener): () => void;
65
- onInvalidate(listener: InvalidationListener): () => void;
66
- onPresence(listener: (scopeKey: string) => void): () => void;
67
- onLeadershipChange(listener: (state: LeadershipState) => void): () => void;
68
- leadershipSnapshot(): LeadershipState | undefined;
69
- securityLifecycle(): Promise<SecurityLifecycle>;
70
- beginSecurityPreflight(): Promise<void>;
71
- /** Key-bearing activation remains available on each concrete host type. */
72
- activateSecurity(): Promise<void>;
73
- query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
74
- mutate(mutations: readonly MutationInput[]): Promise<string>;
75
- patch(table: string, rowId: string, partial: Readonly<Record<string, unknown>>, options?: {
76
- readonly baseVersion?: number;
77
- }): Promise<string>;
78
- purgeLocalData(input: LocalDataPurgeInput): Promise<LocalDataPurgeResult>;
79
- rebootstrapLocalData(input: LocalDataRebootstrapInput): Promise<LocalDataRebootstrapResult>;
80
- querySnapshot<Row = SqlRow>(spec: QueryReadSpec): Promise<QuerySnapshot<Row>>;
81
- statusSnapshot(): Promise<SyncStatusSnapshot>;
82
- diagnosticsSnapshot(request?: ClientDiagnosticsRequest): Promise<ClientDiagnosticsSnapshot>;
83
- conflicts(): Promise<readonly ConflictRecord[]>;
84
- rejections(): Promise<readonly RejectionRecord[]>;
85
- commitOutcome(clientCommitId: string): Promise<CommitOutcome | undefined>;
86
- commitOutcomes(query?: CommitOutcomeQuery): Promise<readonly CommitOutcome[]>;
87
- resolveCommitOutcome(input: ResolveCommitOutcomeInput): Promise<CommitOutcome>;
88
- schemaFloor(): Promise<SchemaFloor | undefined>;
89
- leaseState(): Promise<LeaseState | undefined>;
90
- upgrading(): Promise<boolean>;
91
- syncNeeded(): Promise<boolean>;
92
- pendingCommits(): Promise<unknown[]>;
93
- presence(scopeKey: string): Promise<readonly PresencePeer[]>;
94
- setPresence(scopeKey: string, doc: Record<string, unknown> | null): Promise<void>;
95
- setWindow(base: WindowBase, units: readonly string[]): Promise<void>;
96
- windowState(base: WindowBase): Promise<WindowState>;
97
- }
98
- export declare function normalizeClient(client: SyncClientLike): NormalizedClient;
package/dist/client.js CHANGED
@@ -1,65 +1 @@
1
- /**
2
- * The ONE client interface the React bindings target. Both the direct
3
- * `SyncClient` (constructed on the current thread) and the worker-mode
4
- * `SyncClientHandle` (a promise proxy over the OPFS worker) satisfy it —
5
- * their public surfaces diverge (getters vs methods, sync vs promise), so
6
- * this module normalizes both into a single async-friendly facade the hooks
7
- * consume. That is the "one interface across direct and worker-handle modes"
8
- * the invalidation seam enables.
9
- *
10
- * The normalizer resolves each accessor at call time (function → call it,
11
- * value → read it) and wraps every result in `Promise.resolve`, so a hook
12
- * never has to care which core it holds.
13
- */
14
- import { linkRealtimeSupervisorObservation } from '@syncular/client/realtime-supervisor-observation';
15
- /**
16
- * Read a member by key that is EITHER a value getter (SyncClient) or a
17
- * method returning a value/promise (SyncClientHandle). Read from the client
18
- * so a method keeps its `this` binding; if the read is a function, call it.
19
- */
20
- function resolveMember(client, key) {
21
- const member = client[key];
22
- const value = typeof member === 'function'
23
- ? member.call(client)
24
- : member;
25
- return Promise.resolve(value);
26
- }
27
- export function normalizeClient(client) {
28
- const normalized = {
29
- ...(client.currentSchemaVersion !== undefined
30
- ? { currentSchemaVersion: client.currentSchemaVersion }
31
- : {}),
32
- onChange: (listener) => client.onChange(listener),
33
- onDiagnostics: (listener) => client.onDiagnostics(listener),
34
- onInvalidate: (listener) => client.onInvalidate(listener),
35
- onPresence: (listener) => client.onPresence(listener),
36
- onLeadershipChange: (listener) => client.onLeadershipChange?.(listener) ?? (() => { }),
37
- leadershipSnapshot: () => client.leadershipSnapshot?.(),
38
- securityLifecycle: () => resolveMember(client, 'securityLifecycle'),
39
- beginSecurityPreflight: () => Promise.resolve(client.beginSecurityPreflight()),
40
- activateSecurity: () => Promise.resolve(client.activateSecurity()),
41
- query: (sql, params) => Promise.resolve(client.query(sql, params)),
42
- mutate: (mutations) => Promise.resolve(client.mutate(mutations)),
43
- patch: (table, rowId, partial, options) => Promise.resolve(client.patch(table, rowId, partial, options)),
44
- purgeLocalData: (input) => Promise.resolve(client.purgeLocalData(input)),
45
- rebootstrapLocalData: (input) => Promise.resolve(client.rebootstrapLocalData(input)),
46
- querySnapshot: (spec) => Promise.resolve(client.querySnapshot(spec)),
47
- statusSnapshot: () => Promise.resolve(client.statusSnapshot()),
48
- diagnosticsSnapshot: (request) => Promise.resolve(client.diagnosticsSnapshot(request)),
49
- conflicts: () => resolveMember(client, 'conflicts'),
50
- rejections: () => resolveMember(client, 'rejections'),
51
- commitOutcome: (clientCommitId) => Promise.resolve(client.commitOutcome(clientCommitId)),
52
- commitOutcomes: (query) => Promise.resolve(client.commitOutcomes(query)),
53
- resolveCommitOutcome: (input) => Promise.resolve(client.resolveCommitOutcome(input)),
54
- schemaFloor: () => resolveMember(client, 'schemaFloor'),
55
- leaseState: () => resolveMember(client, 'leaseState'),
56
- upgrading: () => resolveMember(client, 'upgrading'),
57
- syncNeeded: () => resolveMember(client, 'syncNeeded'),
58
- pendingCommits: () => resolveMember(client, 'pendingCommits'),
59
- presence: (scopeKey) => Promise.resolve(client.presence(scopeKey)),
60
- setPresence: (scopeKey, doc) => Promise.resolve(client.setPresence(scopeKey, doc)),
61
- setWindow: (base, units) => Promise.resolve(client.setWindow(base, units)),
62
- windowState: (base) => Promise.resolve(client.windowState(base)),
63
- };
64
- return linkRealtimeSupervisorObservation(normalized, client);
65
- }
1
+ export {};
package/dist/index.d.ts CHANGED
@@ -1,14 +1,13 @@
1
1
  /**
2
2
  * @syncular/react — React bindings with fine-grained live queries
3
3
  * Works against BOTH `SyncClient`
4
- * (direct) and `SyncClientHandle` (worker) through one normalized client
4
+ * (direct) and `SyncClientHandle` (worker) through the canonical client
5
5
  * interface. React 18+ (react is a peer dependency); no other runtime deps.
6
6
  *
7
7
  * See README.md for the invalidation granularity truth and the `tables`
8
8
  * option.
9
9
  */
10
- export type { NormalizedClient, SyncClientLike } from './client.js';
11
- export { normalizeClient } from './client.js';
10
+ export type { SyncClientLike } from './client.js';
12
11
  export { inferTables } from './infer-tables.js';
13
12
  export { type SyncBoundaryActions, type SyncBoundaryState, SyncContext, SyncProvider, type SyncProviderProps, SyncStoreContext, } from './provider.js';
14
13
  export { createSyncClientResource, isSyncClientResource, type SyncClientResource, type SyncClientResourceSnapshot, } from './resource.js';
package/dist/index.js CHANGED
@@ -1,7 +1,3 @@
1
- // `normalizeClient` is the runtime facade the hooks consume; exported so
2
- // alternate hosts (e.g. `@syncular/tauri`) can assert shape-parity against
3
- // the exact normalizer the bindings use.
4
- export { normalizeClient } from './client.js';
5
1
  export { inferTables } from './infer-tables.js';
6
2
  export { SyncContext, SyncProvider, SyncStoreContext, } from './provider.js';
7
3
  export { createSyncClientResource, isSyncClientResource, } from './resource.js';
@@ -1,15 +1,15 @@
1
1
  /**
2
2
  * `SyncProvider` — supplies a `SyncClient` or `SyncClientHandle` to the
3
3
  * hook tree through React context. One provider per client; the hooks read
4
- * the normalized facade. Written with `createElement` (no JSX) so the whole
4
+ * the supplied client. Written with `createElement` (no JSX) so the whole
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
8
  import { ReactiveClientStore, type SyncAvailability } from '@syncular/client';
9
9
  import { type ReactNode } from 'react';
10
- import { type NormalizedClient, type SyncClientLike } from './client.js';
10
+ import { type SyncClientLike } from './client.js';
11
11
  import { type SyncClientResource } from './resource.js';
12
- export declare const SyncContext: import("react").Context<NormalizedClient | undefined>;
12
+ export declare const SyncContext: import("react").Context<SyncClientLike | undefined>;
13
13
  export declare const SyncStoreContext: import("react").Context<ReactiveClientStore | undefined>;
14
14
  export interface SyncProviderProps {
15
15
  /** A `SyncClient` (direct) or `SyncClientHandle` (worker) — both satisfy it. */
package/dist/provider.js CHANGED
@@ -1,13 +1,13 @@
1
1
  /**
2
2
  * `SyncProvider` — supplies a `SyncClient` or `SyncClientHandle` to the
3
3
  * hook tree through React context. One provider per client; the hooks read
4
- * the normalized facade. Written with `createElement` (no JSX) so the whole
4
+ * the supplied client. Written with `createElement` (no JSX) so the whole
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
8
  import { ClientSyncError, ReactiveClientStore, } from '@syncular/client';
9
9
  import { createContext, createElement, useEffect, useMemo, useSyncExternalStore, } from 'react';
10
- import { normalizeClient, } from './client.js';
10
+ import {} from './client.js';
11
11
  import { isSyncClientResource } from './resource.js';
12
12
  export const SyncContext = createContext(undefined);
13
13
  export const SyncStoreContext = createContext(undefined);
@@ -16,10 +16,8 @@ function recordFor(client) {
16
16
  const key = client;
17
17
  let record = stores.get(key);
18
18
  if (record === undefined) {
19
- const normalized = normalizeClient(client);
20
19
  record = {
21
- normalized,
22
- store: new ReactiveClientStore(normalized),
20
+ store: new ReactiveClientStore(client),
23
21
  refs: 0,
24
22
  };
25
23
  stores.set(key, record);
@@ -28,7 +26,7 @@ function recordFor(client) {
28
26
  }
29
27
  function ReadySyncProvider(props) {
30
28
  const record = useMemo(() => recordFor(props.client), [props.client]);
31
- const { normalized, store } = record;
29
+ const { store } = record;
32
30
  const status = useSyncExternalStore(store.status.subscribe, store.status.getSnapshot, store.status.getSnapshot);
33
31
  useEffect(() => {
34
32
  record.refs += 1;
@@ -50,7 +48,7 @@ function ReadySyncProvider(props) {
50
48
  return props.renderBoundary(availability, props.retry === undefined ? {} : { retry: props.retry });
51
49
  }
52
50
  }
53
- return createElement(SyncContext.Provider, { value: normalized }, createElement(SyncStoreContext.Provider, { value: store }, props.children));
51
+ return createElement(SyncContext.Provider, { value: props.client }, createElement(SyncStoreContext.Provider, { value: store }, props.children));
54
52
  }
55
53
  const noSubscribe = () => () => { };
56
54
  export function SyncProvider(props) {
@@ -3,4 +3,4 @@
3
3
  * Vite HMR uses it to replace a worker owner after a Syncular package upgrade
4
4
  * even when the application's generated schema version did not change.
5
5
  */
6
- export declare const SYNCULAR_REACT_RUNTIME_VERSION = "0.15.47";
6
+ export declare const SYNCULAR_REACT_RUNTIME_VERSION = "0.16.1";
@@ -3,4 +3,4 @@
3
3
  * Vite HMR uses it to replace a worker owner after a Syncular package upgrade
4
4
  * even when the application's generated schema version did not change.
5
5
  */
6
- export const SYNCULAR_REACT_RUNTIME_VERSION = '0.15.47';
6
+ export const SYNCULAR_REACT_RUNTIME_VERSION = '0.16.1';
@@ -1,5 +1,5 @@
1
1
  import type { ReactiveClientStore } from '@syncular/client';
2
- import type { NormalizedClient } from './client.js';
3
- /** Read the normalized client from context; throws outside a `SyncProvider`. */
4
- export declare function useSyncClient(): NormalizedClient;
2
+ import type { SyncClientLike } from './client.js';
3
+ /** Read the supplied client from context; throws outside a `SyncProvider`. */
4
+ export declare function useSyncClient(): SyncClientLike;
5
5
  export declare function useReactiveStore(): ReactiveClientStore;
@@ -1,6 +1,6 @@
1
1
  import { useContext } from 'react';
2
2
  import { SyncContext, SyncStoreContext } from './provider.js';
3
- /** Read the normalized client from context; throws outside a `SyncProvider`. */
3
+ /** Read the supplied client from context; throws outside a `SyncProvider`. */
4
4
  export function useSyncClient() {
5
5
  const client = useContext(SyncContext);
6
6
  if (client === undefined) {
@@ -19,7 +19,9 @@ export function useDiagnostics(options = {}) {
19
19
  const refresh = useCallback(() => {
20
20
  const current = ++generation.current;
21
21
  setIsLoading(true);
22
- void client.diagnosticsSnapshot(request).then((next) => {
22
+ void Promise.resolve()
23
+ .then(() => client.diagnosticsSnapshot(request))
24
+ .then((next) => {
23
25
  if (generation.current !== current)
24
26
  return;
25
27
  setSnapshot(next);
@@ -9,12 +9,14 @@ export interface SyncTableDescriptor<Row, Insert, Update, Id> {
9
9
  readonly __id?: Id;
10
10
  }
11
11
  export interface UseMutationOptions {
12
- readonly onSuccess?: (clientCommitId: string) => void;
12
+ /** Runs after durable local enqueue. The server outcome can still reject this commit. */
13
+ readonly onEnqueued?: (clientCommitId: string) => void;
13
14
  readonly onError?: (error: Error) => void;
14
15
  }
15
16
  export interface UseMutationResult {
16
17
  mutate: (mutations: readonly MutationInput[]) => Promise<string>;
17
18
  readonly pendingCount: number;
19
+ /** True while at least one local enqueue is unresolved. */
18
20
  readonly isPending: boolean;
19
21
  readonly error: Error | undefined;
20
22
  readonly resetError: () => void;
@@ -18,7 +18,7 @@ export function useMutation(tableOrOptions, maybeOptions) {
18
18
  setError(undefined);
19
19
  try {
20
20
  const id = await operation();
21
- optionsRef.current?.onSuccess?.(id);
21
+ optionsRef.current?.onEnqueued?.(id);
22
22
  return id;
23
23
  }
24
24
  catch (caught) {
@@ -8,30 +8,41 @@
8
8
  */
9
9
  import { useEffect, useState } from 'react';
10
10
  import { useSyncClient } from './use-client.js';
11
+ const EMPTY_PEERS = [];
11
12
  export function usePresence(scopeKey) {
12
13
  const client = useSyncClient();
13
- const [peers, setPeers] = useState([]);
14
+ const [snapshot, setSnapshot] = useState(() => ({ client, scopeKey, peers: EMPTY_PEERS }));
15
+ if (snapshot.client !== client || snapshot.scopeKey !== scopeKey) {
16
+ setSnapshot({ client, scopeKey, peers: EMPTY_PEERS });
17
+ }
14
18
  useEffect(() => {
15
- let cancelled = false;
19
+ let generation = 0;
16
20
  const read = () => {
17
- Promise.resolve(client.presence(scopeKey))
18
- .then((list) => {
19
- if (!cancelled)
20
- setPeers(list);
21
+ const request = ++generation;
22
+ void Promise.resolve()
23
+ .then(() => client.presence(scopeKey))
24
+ .then((peers) => {
25
+ if (request === generation) {
26
+ setSnapshot((previous) => previous.client === client && previous.scopeKey === scopeKey
27
+ ? { client, scopeKey, peers }
28
+ : previous);
29
+ }
21
30
  })
22
31
  .catch(() => {
23
- /* transient the next presence event re-reads */
32
+ // Keep the current scope's last snapshot until the next event.
24
33
  });
25
34
  };
26
- read();
27
35
  const unsubscribe = client.onPresence((changedKey) => {
28
36
  if (changedKey === scopeKey)
29
37
  read();
30
38
  });
39
+ read();
31
40
  return () => {
32
- cancelled = true;
41
+ generation += 1;
33
42
  unsubscribe();
34
43
  };
35
44
  }, [client, scopeKey]);
36
- return peers;
45
+ return snapshot.client === client && snapshot.scopeKey === scopeKey
46
+ ? snapshot.peers
47
+ : EMPTY_PEERS;
37
48
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@syncular/react",
3
- "version": "0.15.47",
3
+ "version": "0.16.1",
4
4
  "description": "React hooks for Syncular offline-first sync",
5
5
  "license": "Apache-2.0",
6
6
  "author": "Benjamin Kniffler",
@@ -55,17 +55,18 @@
55
55
  "test": "bun test --preload ./test/setup.ts"
56
56
  },
57
57
  "dependencies": {
58
- "@syncular/client": "0.15.47"
58
+ "@syncular/client": "0.16.1"
59
59
  },
60
60
  "peerDependencies": {
61
61
  "react": ">=18.0.0"
62
62
  },
63
63
  "devDependencies": {
64
64
  "@happy-dom/global-registrator": "^20.0.0",
65
- "@syncular/core": "0.15.47",
66
- "@syncular/server": "0.15.47",
65
+ "@syncular/core": "0.16.1",
66
+ "@syncular/server": "0.16.1",
67
67
  "@testing-library/react": "^16.1.0",
68
68
  "@types/react": "^18.3.0",
69
+ "@types/react-dom": "^18.3.0",
69
70
  "react": "^18.3.1",
70
71
  "react-dom": "^18.3.1",
71
72
  "react-router-dom": "^7.18.1",
package/src/client.ts CHANGED
@@ -1,28 +1,10 @@
1
- /**
2
- * The ONE client interface the React bindings target. Both the direct
3
- * `SyncClient` (constructed on the current thread) and the worker-mode
4
- * `SyncClientHandle` (a promise proxy over the OPFS worker) satisfy it —
5
- * their public surfaces diverge (getters vs methods, sync vs promise), so
6
- * this module normalizes both into a single async-friendly facade the hooks
7
- * consume. That is the "one interface across direct and worker-handle modes"
8
- * the invalidation seam enables.
9
- *
10
- * The normalizer resolves each accessor at call time (function → call it,
11
- * value → read it) and wraps every result in `Promise.resolve`, so a hook
12
- * never has to care which core it holds.
13
- */
14
-
1
+ /** The provider uses the supplied client identity and canonical snapshot methods. */
15
2
  import type {
3
+ ClientSnapshotReader,
16
4
  ClientChangeListener,
17
5
  ClientDiagnosticsListener,
18
- ClientDiagnosticsRequest,
19
- ClientDiagnosticsSnapshot,
20
- CommitOutcome,
21
- CommitOutcomeQuery,
22
- ConflictRecord,
23
6
  InvalidationListener,
24
7
  LeadershipState,
25
- LeaseState,
26
8
  LocalDataPurgeInput,
27
9
  LocalDataPurgeResult,
28
10
  LocalDataRebootstrapInput,
@@ -31,25 +13,14 @@ import type {
31
13
  PresencePeer,
32
14
  QueryReadSpec,
33
15
  QuerySnapshot,
34
- RejectionRecord,
35
- ResolveCommitOutcomeInput,
36
- SchemaFloor,
37
16
  SecurityLifecycle,
38
17
  SqlRow,
39
18
  SqlValue,
40
- SyncStatusSnapshot,
41
19
  WindowBase,
42
20
  WindowState,
43
21
  } from '@syncular/client';
44
- import { linkRealtimeSupervisorObservation } from '@syncular/client/realtime-supervisor-observation';
45
22
 
46
- /**
47
- * The structural union of `SyncClient` and `SyncClientHandle`. Members that
48
- * diverge are typed as "value or method, sync or promise"; {@link normalizeClient}
49
- * collapses the divergence. Only what the hooks use is listed — the bindings
50
- * never reach past this surface.
51
- */
52
- export interface SyncClientLike {
23
+ export interface SyncClientLike extends ClientSnapshotReader {
53
24
  readonly currentSchemaVersion?: number;
54
25
  onChange(listener: ClientChangeListener): () => void;
55
26
  onDiagnostics(listener: ClientDiagnosticsListener): () => void;
@@ -57,9 +28,7 @@ export interface SyncClientLike {
57
28
  onPresence(listener: (scopeKey: string) => void): () => void;
58
29
  onLeadershipChange?(listener: (state: LeadershipState) => void): () => void;
59
30
  leadershipSnapshot?(): LeadershipState | undefined;
60
- securityLifecycle:
61
- | SecurityLifecycle
62
- | (() => SecurityLifecycle | Promise<SecurityLifecycle>);
31
+ securityLifecycle(): SecurityLifecycle | Promise<SecurityLifecycle>;
63
32
  beginSecurityPreflight(): void | Promise<void>;
64
33
  /** Key-bearing activation remains available on each concrete host type. */
65
34
  activateSecurity(): void | Promise<void>;
@@ -83,35 +52,6 @@ export interface SyncClientLike {
83
52
  querySnapshot<Row = SqlRow>(
84
53
  spec: QueryReadSpec,
85
54
  ): QuerySnapshot<Row> | Promise<QuerySnapshot<Row>>;
86
- statusSnapshot(): SyncStatusSnapshot | Promise<SyncStatusSnapshot>;
87
- diagnosticsSnapshot(
88
- request?: ClientDiagnosticsRequest,
89
- ): ClientDiagnosticsSnapshot | Promise<ClientDiagnosticsSnapshot>;
90
- conflicts:
91
- | readonly ConflictRecord[]
92
- | (() => readonly ConflictRecord[] | Promise<readonly ConflictRecord[]>);
93
- rejections:
94
- | readonly RejectionRecord[]
95
- | (() => readonly RejectionRecord[] | Promise<readonly RejectionRecord[]>);
96
- commitOutcome(
97
- clientCommitId: string,
98
- ): CommitOutcome | undefined | Promise<CommitOutcome | undefined>;
99
- commitOutcomes(
100
- query?: CommitOutcomeQuery,
101
- ): readonly CommitOutcome[] | Promise<readonly CommitOutcome[]>;
102
- resolveCommitOutcome(
103
- input: ResolveCommitOutcomeInput,
104
- ): CommitOutcome | Promise<CommitOutcome>;
105
- schemaFloor:
106
- | SchemaFloor
107
- | undefined
108
- | (() => SchemaFloor | undefined | Promise<SchemaFloor | undefined>);
109
- leaseState:
110
- | LeaseState
111
- | undefined
112
- | (() => LeaseState | undefined | Promise<LeaseState | undefined>);
113
- upgrading: boolean | (() => boolean | Promise<boolean>);
114
- syncNeeded: boolean | (() => boolean | Promise<boolean>);
115
55
  pendingCommits: () => unknown[] | Promise<unknown[]>;
116
56
  presence(
117
57
  scopeKey: string,
@@ -125,119 +65,3 @@ export interface SyncClientLike {
125
65
  /** §4.8 completeness oracle (I3): the windowed-in units for a base. */
126
66
  windowState(base: WindowBase): WindowState | Promise<WindowState>;
127
67
  }
128
-
129
- /**
130
- * Read a member by key that is EITHER a value getter (SyncClient) or a
131
- * method returning a value/promise (SyncClientHandle). Read from the client
132
- * so a method keeps its `this` binding; if the read is a function, call it.
133
- */
134
- function resolveMember<T>(
135
- client: SyncClientLike,
136
- key: keyof SyncClientLike,
137
- ): Promise<T> {
138
- const member = client[key] as unknown;
139
- const value =
140
- typeof member === 'function'
141
- ? (member as (this: SyncClientLike) => T | Promise<T>).call(client)
142
- : (member as T);
143
- return Promise.resolve(value as T);
144
- }
145
-
146
- /** The uniform async facade the hooks actually call. */
147
- export interface NormalizedClient {
148
- readonly currentSchemaVersion?: number;
149
- onChange(listener: ClientChangeListener): () => void;
150
- onDiagnostics(listener: ClientDiagnosticsListener): () => void;
151
- onInvalidate(listener: InvalidationListener): () => void;
152
- onPresence(listener: (scopeKey: string) => void): () => void;
153
- onLeadershipChange(listener: (state: LeadershipState) => void): () => void;
154
- leadershipSnapshot(): LeadershipState | undefined;
155
- securityLifecycle(): Promise<SecurityLifecycle>;
156
- beginSecurityPreflight(): Promise<void>;
157
- /** Key-bearing activation remains available on each concrete host type. */
158
- activateSecurity(): Promise<void>;
159
- query(sql: string, params?: readonly SqlValue[]): Promise<SqlRow[]>;
160
- mutate(mutations: readonly MutationInput[]): Promise<string>;
161
- patch(
162
- table: string,
163
- rowId: string,
164
- partial: Readonly<Record<string, unknown>>,
165
- options?: { readonly baseVersion?: number },
166
- ): Promise<string>;
167
- purgeLocalData(input: LocalDataPurgeInput): Promise<LocalDataPurgeResult>;
168
- rebootstrapLocalData(
169
- input: LocalDataRebootstrapInput,
170
- ): Promise<LocalDataRebootstrapResult>;
171
- querySnapshot<Row = SqlRow>(spec: QueryReadSpec): Promise<QuerySnapshot<Row>>;
172
- statusSnapshot(): Promise<SyncStatusSnapshot>;
173
- diagnosticsSnapshot(
174
- request?: ClientDiagnosticsRequest,
175
- ): Promise<ClientDiagnosticsSnapshot>;
176
- conflicts(): Promise<readonly ConflictRecord[]>;
177
- rejections(): Promise<readonly RejectionRecord[]>;
178
- commitOutcome(clientCommitId: string): Promise<CommitOutcome | undefined>;
179
- commitOutcomes(query?: CommitOutcomeQuery): Promise<readonly CommitOutcome[]>;
180
- resolveCommitOutcome(
181
- input: ResolveCommitOutcomeInput,
182
- ): Promise<CommitOutcome>;
183
- schemaFloor(): Promise<SchemaFloor | undefined>;
184
- leaseState(): Promise<LeaseState | undefined>;
185
- upgrading(): Promise<boolean>;
186
- syncNeeded(): Promise<boolean>;
187
- pendingCommits(): Promise<unknown[]>;
188
- presence(scopeKey: string): Promise<readonly PresencePeer[]>;
189
- setPresence(
190
- scopeKey: string,
191
- doc: Record<string, unknown> | null,
192
- ): Promise<void>;
193
- setWindow(base: WindowBase, units: readonly string[]): Promise<void>;
194
- windowState(base: WindowBase): Promise<WindowState>;
195
- }
196
-
197
- export function normalizeClient(client: SyncClientLike): NormalizedClient {
198
- const normalized: NormalizedClient = {
199
- ...(client.currentSchemaVersion !== undefined
200
- ? { currentSchemaVersion: client.currentSchemaVersion }
201
- : {}),
202
- onChange: (listener) => client.onChange(listener),
203
- onDiagnostics: (listener) => client.onDiagnostics(listener),
204
- onInvalidate: (listener) => client.onInvalidate(listener),
205
- onPresence: (listener) => client.onPresence(listener),
206
- onLeadershipChange: (listener) =>
207
- client.onLeadershipChange?.(listener) ?? (() => {}),
208
- leadershipSnapshot: () => client.leadershipSnapshot?.(),
209
- securityLifecycle: () => resolveMember(client, 'securityLifecycle'),
210
- beginSecurityPreflight: () =>
211
- Promise.resolve(client.beginSecurityPreflight()),
212
- activateSecurity: () => Promise.resolve(client.activateSecurity()),
213
- query: (sql, params) => Promise.resolve(client.query(sql, params)),
214
- mutate: (mutations) => Promise.resolve(client.mutate(mutations)),
215
- patch: (table, rowId, partial, options) =>
216
- Promise.resolve(client.patch(table, rowId, partial, options)),
217
- purgeLocalData: (input) => Promise.resolve(client.purgeLocalData(input)),
218
- rebootstrapLocalData: (input) =>
219
- Promise.resolve(client.rebootstrapLocalData(input)),
220
- querySnapshot: (spec) => Promise.resolve(client.querySnapshot(spec)),
221
- statusSnapshot: () => Promise.resolve(client.statusSnapshot()),
222
- diagnosticsSnapshot: (request) =>
223
- Promise.resolve(client.diagnosticsSnapshot(request)),
224
- conflicts: () => resolveMember(client, 'conflicts'),
225
- rejections: () => resolveMember(client, 'rejections'),
226
- commitOutcome: (clientCommitId) =>
227
- Promise.resolve(client.commitOutcome(clientCommitId)),
228
- commitOutcomes: (query) => Promise.resolve(client.commitOutcomes(query)),
229
- resolveCommitOutcome: (input) =>
230
- Promise.resolve(client.resolveCommitOutcome(input)),
231
- schemaFloor: () => resolveMember(client, 'schemaFloor'),
232
- leaseState: () => resolveMember(client, 'leaseState'),
233
- upgrading: () => resolveMember(client, 'upgrading'),
234
- syncNeeded: () => resolveMember(client, 'syncNeeded'),
235
- pendingCommits: () => resolveMember(client, 'pendingCommits'),
236
- presence: (scopeKey) => Promise.resolve(client.presence(scopeKey)),
237
- setPresence: (scopeKey, doc) =>
238
- Promise.resolve(client.setPresence(scopeKey, doc)),
239
- setWindow: (base, units) => Promise.resolve(client.setWindow(base, units)),
240
- windowState: (base) => Promise.resolve(client.windowState(base)),
241
- };
242
- return linkRealtimeSupervisorObservation(normalized, client);
243
- }
package/src/index.ts CHANGED
@@ -1,17 +1,13 @@
1
1
  /**
2
2
  * @syncular/react — React bindings with fine-grained live queries
3
3
  * Works against BOTH `SyncClient`
4
- * (direct) and `SyncClientHandle` (worker) through one normalized client
4
+ * (direct) and `SyncClientHandle` (worker) through the canonical client
5
5
  * interface. React 18+ (react is a peer dependency); no other runtime deps.
6
6
  *
7
7
  * See README.md for the invalidation granularity truth and the `tables`
8
8
  * option.
9
9
  */
10
- export type { NormalizedClient, SyncClientLike } from './client';
11
- // `normalizeClient` is the runtime facade the hooks consume; exported so
12
- // alternate hosts (e.g. `@syncular/tauri`) can assert shape-parity against
13
- // the exact normalizer the bindings use.
14
- export { normalizeClient } from './client';
10
+ export type { SyncClientLike } from './client';
15
11
  export { inferTables } from './infer-tables';
16
12
  export {
17
13
  type SyncBoundaryActions,
package/src/provider.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * `SyncProvider` — supplies a `SyncClient` or `SyncClientHandle` to the
3
3
  * hook tree through React context. One provider per client; the hooks read
4
- * the normalized facade. Written with `createElement` (no JSX) so the whole
4
+ * the supplied client. Written with `createElement` (no JSX) so the whole
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
  */
@@ -18,16 +18,10 @@ import {
18
18
  useMemo,
19
19
  useSyncExternalStore,
20
20
  } from 'react';
21
- import {
22
- type NormalizedClient,
23
- normalizeClient,
24
- type SyncClientLike,
25
- } from './client';
21
+ import { type SyncClientLike } from './client';
26
22
  import { isSyncClientResource, type SyncClientResource } from './resource';
27
23
 
28
- export const SyncContext = createContext<NormalizedClient | undefined>(
29
- undefined,
30
- );
24
+ export const SyncContext = createContext<SyncClientLike | undefined>(undefined);
31
25
  export const SyncStoreContext = createContext<ReactiveClientStore | undefined>(
32
26
  undefined,
33
27
  );
@@ -61,7 +55,6 @@ export interface SyncBoundaryActions {
61
55
  }
62
56
 
63
57
  interface ClientRecord {
64
- readonly normalized: NormalizedClient;
65
58
  readonly store: ReactiveClientStore;
66
59
  refs: number;
67
60
  }
@@ -72,10 +65,8 @@ function recordFor(client: SyncClientLike): ClientRecord {
72
65
  const key = client as object;
73
66
  let record = stores.get(key);
74
67
  if (record === undefined) {
75
- const normalized = normalizeClient(client);
76
68
  record = {
77
- normalized,
78
- store: new ReactiveClientStore(normalized),
69
+ store: new ReactiveClientStore(client),
79
70
  refs: 0,
80
71
  };
81
72
  stores.set(key, record);
@@ -92,7 +83,7 @@ interface ReadySyncProviderProps {
92
83
 
93
84
  function ReadySyncProvider(props: ReadySyncProviderProps): ReactNode {
94
85
  const record = useMemo(() => recordFor(props.client), [props.client]);
95
- const { normalized, store } = record;
86
+ const { store } = record;
96
87
  const status = useSyncExternalStore(
97
88
  store.status.subscribe,
98
89
  store.status.getSnapshot,
@@ -125,7 +116,7 @@ function ReadySyncProvider(props: ReadySyncProviderProps): ReactNode {
125
116
  }
126
117
  return createElement(
127
118
  SyncContext.Provider,
128
- { value: normalized },
119
+ { value: props.client },
129
120
  createElement(SyncStoreContext.Provider, { value: store }, props.children),
130
121
  );
131
122
  }
@@ -3,4 +3,4 @@
3
3
  * Vite HMR uses it to replace a worker owner after a Syncular package upgrade
4
4
  * even when the application's generated schema version did not change.
5
5
  */
6
- export const SYNCULAR_REACT_RUNTIME_VERSION = '0.15.47';
6
+ export const SYNCULAR_REACT_RUNTIME_VERSION = '0.16.1';
package/src/use-client.ts CHANGED
@@ -1,10 +1,10 @@
1
1
  import type { ReactiveClientStore } from '@syncular/client';
2
2
  import { useContext } from 'react';
3
- import type { NormalizedClient } from './client';
3
+ import type { SyncClientLike } from './client';
4
4
  import { SyncContext, SyncStoreContext } from './provider';
5
5
 
6
- /** Read the normalized client from context; throws outside a `SyncProvider`. */
7
- export function useSyncClient(): NormalizedClient {
6
+ /** Read the supplied client from context; throws outside a `SyncProvider`. */
7
+ export function useSyncClient(): SyncClientLike {
8
8
  const client = useContext(SyncContext);
9
9
  if (client === undefined) {
10
10
  throw new Error(
@@ -47,19 +47,23 @@ export function useDiagnostics(
47
47
  const refresh = useCallback(() => {
48
48
  const current = ++generation.current;
49
49
  setIsLoading(true);
50
- void client.diagnosticsSnapshot(request).then(
51
- (next) => {
52
- if (generation.current !== current) return;
53
- setSnapshot(next);
54
- setError(undefined);
55
- setIsLoading(false);
56
- },
57
- (reason: unknown) => {
58
- if (generation.current !== current) return;
59
- setError(reason instanceof Error ? reason : new Error(String(reason)));
60
- setIsLoading(false);
61
- },
62
- );
50
+ void Promise.resolve()
51
+ .then(() => client.diagnosticsSnapshot(request))
52
+ .then(
53
+ (next) => {
54
+ if (generation.current !== current) return;
55
+ setSnapshot(next);
56
+ setError(undefined);
57
+ setIsLoading(false);
58
+ },
59
+ (reason: unknown) => {
60
+ if (generation.current !== current) return;
61
+ setError(
62
+ reason instanceof Error ? reason : new Error(String(reason)),
63
+ );
64
+ setIsLoading(false);
65
+ },
66
+ );
63
67
  }, [client, request]);
64
68
 
65
69
  useEffect(() => {
@@ -13,13 +13,15 @@ export interface SyncTableDescriptor<Row, Insert, Update, Id> {
13
13
  }
14
14
 
15
15
  export interface UseMutationOptions {
16
- readonly onSuccess?: (clientCommitId: string) => void;
16
+ /** Runs after durable local enqueue. The server outcome can still reject this commit. */
17
+ readonly onEnqueued?: (clientCommitId: string) => void;
17
18
  readonly onError?: (error: Error) => void;
18
19
  }
19
20
 
20
21
  export interface UseMutationResult {
21
22
  mutate: (mutations: readonly MutationInput[]) => Promise<string>;
22
23
  readonly pendingCount: number;
24
+ /** True while at least one local enqueue is unresolved. */
23
25
  readonly isPending: boolean;
24
26
  readonly error: Error | undefined;
25
27
  readonly resetError: () => void;
@@ -66,12 +68,12 @@ export function useMutation<Row, Insert, Update, Id>(
66
68
  const resetError = useCallback(() => setError(undefined), []);
67
69
 
68
70
  const run = useCallback(
69
- async (operation: () => Promise<string>): Promise<string> => {
71
+ async (operation: () => string | Promise<string>): Promise<string> => {
70
72
  setPendingCount((count) => count + 1);
71
73
  setError(undefined);
72
74
  try {
73
75
  const id = await operation();
74
- optionsRef.current?.onSuccess?.(id);
76
+ optionsRef.current?.onEnqueued?.(id);
75
77
  return id;
76
78
  } catch (caught) {
77
79
  const wrapped =
@@ -9,32 +9,52 @@
9
9
 
10
10
  import type { PresencePeer } from '@syncular/client';
11
11
  import { useEffect, useState } from 'react';
12
+ import type { SyncClientLike } from './client';
12
13
  import { useSyncClient } from './use-client';
13
14
 
15
+ const EMPTY_PEERS: readonly PresencePeer[] = [];
16
+
14
17
  export function usePresence(scopeKey: string): readonly PresencePeer[] {
15
18
  const client = useSyncClient();
16
- const [peers, setPeers] = useState<readonly PresencePeer[]>([]);
19
+ const [snapshot, setSnapshot] = useState<{
20
+ client: SyncClientLike;
21
+ scopeKey: string;
22
+ peers: readonly PresencePeer[];
23
+ }>(() => ({ client, scopeKey, peers: EMPTY_PEERS }));
24
+ if (snapshot.client !== client || snapshot.scopeKey !== scopeKey) {
25
+ setSnapshot({ client, scopeKey, peers: EMPTY_PEERS });
26
+ }
17
27
 
18
28
  useEffect(() => {
19
- let cancelled = false;
29
+ let generation = 0;
20
30
  const read = () => {
21
- Promise.resolve(client.presence(scopeKey))
22
- .then((list) => {
23
- if (!cancelled) setPeers(list);
31
+ const request = ++generation;
32
+ void Promise.resolve()
33
+ .then(() => client.presence(scopeKey))
34
+ .then((peers) => {
35
+ if (request === generation) {
36
+ setSnapshot((previous) =>
37
+ previous.client === client && previous.scopeKey === scopeKey
38
+ ? { client, scopeKey, peers }
39
+ : previous,
40
+ );
41
+ }
24
42
  })
25
43
  .catch(() => {
26
- /* transient the next presence event re-reads */
44
+ // Keep the current scope's last snapshot until the next event.
27
45
  });
28
46
  };
29
- read();
30
47
  const unsubscribe = client.onPresence((changedKey) => {
31
48
  if (changedKey === scopeKey) read();
32
49
  });
50
+ read();
33
51
  return () => {
34
- cancelled = true;
52
+ generation += 1;
35
53
  unsubscribe();
36
54
  };
37
55
  }, [client, scopeKey]);
38
56
 
39
- return peers;
57
+ return snapshot.client === client && snapshot.scopeKey === scopeKey
58
+ ? snapshot.peers
59
+ : EMPTY_PEERS;
40
60
  }