@syncular/client-react 0.0.6-135 → 0.0.6-136

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.
@@ -54,6 +54,7 @@ import { type Kysely, sql } from 'kysely';
54
54
  import {
55
55
  createContext,
56
56
  type ReactNode,
57
+ startTransition,
57
58
  useCallback,
58
59
  useContext,
59
60
  useEffect,
@@ -152,6 +153,23 @@ export interface SyncProviderProps<
152
153
  onError?: (error: SyncError) => void;
153
154
  onConflict?: (conflict: ConflictInfo) => void;
154
155
  onDataChange?: (scopes: string[]) => void;
156
+ /**
157
+ * Debounce window (ms) for coalescing `data:change` events.
158
+ * - `0` (default): emit immediately
159
+ * - `>0`: merge scopes and emit once per window
160
+ */
161
+ dataChangeDebounceMs?: number;
162
+ /**
163
+ * Debounce override while sync is actively running.
164
+ * Falls back to `dataChangeDebounceMs` when omitted.
165
+ */
166
+ dataChangeDebounceMsWhenSyncing?: number;
167
+ /**
168
+ * Debounce override while connection is reconnecting.
169
+ * Falls back to `dataChangeDebounceMsWhenSyncing` (if syncing) and then
170
+ * `dataChangeDebounceMs` when omitted.
171
+ */
172
+ dataChangeDebounceMsWhenReconnecting?: number;
155
173
  plugins?: SyncClientPlugin[];
156
174
  /** Custom SHA-256 hash function (for platforms without crypto.subtle, e.g. React Native) */
157
175
  sha256?: (bytes: Uint8Array) => Promise<string>;
@@ -317,6 +335,13 @@ export interface UseSyncQueryResult<T> {
317
335
  refetch: () => Promise<void>;
318
336
  }
319
337
 
338
+ interface UseSyncQueryMetrics {
339
+ executions: number;
340
+ coalescedRefreshes: number;
341
+ skippedDataUpdates: number;
342
+ lastDurationMs: number | null;
343
+ }
344
+
320
345
  export interface UseSyncQueryOptions {
321
346
  enabled?: boolean;
322
347
  deps?: unknown[];
@@ -324,6 +349,15 @@ export interface UseSyncQueryOptions {
324
349
  watchTables?: string[];
325
350
  pollIntervalMs?: number;
326
351
  staleAfterMs?: number;
352
+ /**
353
+ * If true (default), non-urgent hook state updates are scheduled in
354
+ * `startTransition` to keep UI interactions responsive under bursty sync.
355
+ */
356
+ transitionUpdates?: boolean;
357
+ /**
358
+ * Optional low-overhead instrumentation callback for query executions.
359
+ */
360
+ onMetrics?: (metrics: UseSyncQueryMetrics) => void;
327
361
  }
328
362
 
329
363
  export interface UseQueryResult<T> {
@@ -428,6 +462,24 @@ export interface UseOutboxResult {
428
462
  clearAll: () => Promise<number>;
429
463
  }
430
464
 
465
+ interface UseOutboxMetrics {
466
+ refreshes: number;
467
+ coalescedRefreshes: number;
468
+ lastDurationMs: number | null;
469
+ }
470
+
471
+ interface UseOutboxOptions {
472
+ /**
473
+ * If true (default), non-urgent outbox state updates are scheduled in
474
+ * `startTransition` to reduce render contention during sync bursts.
475
+ */
476
+ transitionUpdates?: boolean;
477
+ /**
478
+ * Optional low-overhead instrumentation callback for outbox refreshes.
479
+ */
480
+ onMetrics?: (metrics: UseOutboxMetrics) => void;
481
+ }
482
+
431
483
  export interface UsePresenceResult<TMetadata = Record<string, unknown>> {
432
484
  presence: PresenceEntry<TMetadata>[];
433
485
  isLoading: boolean;
@@ -473,6 +525,9 @@ export function createSyncularReact<
473
525
  onError,
474
526
  onConflict,
475
527
  onDataChange,
528
+ dataChangeDebounceMs,
529
+ dataChangeDebounceMsWhenSyncing,
530
+ dataChangeDebounceMsWhenReconnecting,
476
531
  plugins,
477
532
  sha256,
478
533
  autoStart = true,
@@ -505,6 +560,9 @@ export function createSyncularReact<
505
560
  onError,
506
561
  onConflict,
507
562
  onDataChange,
563
+ dataChangeDebounceMs,
564
+ dataChangeDebounceMsWhenSyncing,
565
+ dataChangeDebounceMsWhenReconnecting,
508
566
  plugins,
509
567
  sha256,
510
568
  }),
@@ -528,6 +586,9 @@ export function createSyncularReact<
528
586
  onError,
529
587
  onConflict,
530
588
  onDataChange,
589
+ dataChangeDebounceMs,
590
+ dataChangeDebounceMsWhenSyncing,
591
+ dataChangeDebounceMsWhenReconnecting,
531
592
  plugins,
532
593
  sha256,
533
594
  ]
@@ -793,23 +854,19 @@ export function createSyncularReact<
793
854
  function useTransportHealth(): UseTransportHealthResult {
794
855
  const engine = useEngine();
795
856
 
857
+ const getSnapshot = useCallback(
858
+ () => engine.getTransportHealth(),
859
+ [engine]
860
+ );
796
861
  const health = useSyncExternalStore(
797
862
  useCallback(
798
863
  (callback) => {
799
- const unsubscribers = [
800
- engine.subscribe(callback),
801
- engine.on('connection:change', callback),
802
- engine.on('sync:complete', callback),
803
- engine.on('sync:error', callback),
804
- ];
805
- return () => {
806
- for (const unsubscribe of unsubscribers) unsubscribe();
807
- };
864
+ return engine.subscribeSelector(getSnapshot, callback);
808
865
  },
809
- [engine]
866
+ [engine, getSnapshot]
810
867
  ),
811
- useCallback(() => engine.getTransportHealth(), [engine]),
812
- useCallback(() => engine.getTransportHealth(), [engine])
868
+ getSnapshot,
869
+ getSnapshot
813
870
  );
814
871
 
815
872
  return useMemo(() => ({ health }), [health]);
@@ -823,6 +880,7 @@ export function createSyncularReact<
823
880
  refreshOn: readonly SyncEngineEventName[];
824
881
  pollIntervalMs?: number;
825
882
  shouldPoll?: (value: T) => boolean;
883
+ transitionUpdates?: boolean;
826
884
  }): {
827
885
  value: T;
828
886
  isLoading: boolean;
@@ -830,8 +888,14 @@ export function createSyncularReact<
830
888
  refresh: () => Promise<void>;
831
889
  } {
832
890
  const engine = useEngine();
833
- const { initialValue, load, refreshOn, pollIntervalMs, shouldPoll } =
834
- options;
891
+ const {
892
+ initialValue,
893
+ load,
894
+ refreshOn,
895
+ pollIntervalMs,
896
+ shouldPoll,
897
+ transitionUpdates = true,
898
+ } = options;
835
899
 
836
900
  const loadRef = useRef(load);
837
901
  loadRef.current = load;
@@ -841,8 +905,20 @@ export function createSyncularReact<
841
905
  const [error, setError] = useState<Error | null>(null);
842
906
  const loadedRef = useRef(false);
843
907
  const versionRef = useRef(0);
908
+ const inFlightRefreshRef = useRef<Promise<void> | null>(null);
909
+ const refreshQueuedRef = useRef(false);
910
+ const applyUpdate = useCallback(
911
+ (update: () => void) => {
912
+ if (transitionUpdates) {
913
+ startTransition(update);
914
+ return;
915
+ }
916
+ update();
917
+ },
918
+ [transitionUpdates]
919
+ );
844
920
 
845
- const refresh = useCallback(async () => {
921
+ const refreshOnce = useCallback(async () => {
846
922
  const version = ++versionRef.current;
847
923
  const isCurrent = () => version === versionRef.current;
848
924
 
@@ -853,18 +929,45 @@ export function createSyncularReact<
853
929
  try {
854
930
  const next = await loadRef.current();
855
931
  if (!isCurrent()) return;
856
- setValue(next);
857
- setError(null);
932
+ applyUpdate(() => {
933
+ setValue(next);
934
+ setError(null);
935
+ });
858
936
  } catch (err) {
859
937
  if (!isCurrent()) return;
860
- setError(err instanceof Error ? err : new Error(String(err)));
938
+ applyUpdate(() => {
939
+ setError(err instanceof Error ? err : new Error(String(err)));
940
+ });
861
941
  } finally {
862
942
  if (isCurrent()) {
863
943
  loadedRef.current = true;
864
- setIsLoading(false);
944
+ applyUpdate(() => {
945
+ setIsLoading(false);
946
+ });
865
947
  }
866
948
  }
867
- }, []);
949
+ }, [applyUpdate]);
950
+
951
+ const refresh = useCallback(async () => {
952
+ refreshQueuedRef.current = true;
953
+ if (inFlightRefreshRef.current) {
954
+ await inFlightRefreshRef.current;
955
+ return;
956
+ }
957
+
958
+ const runLoop = async () => {
959
+ while (refreshQueuedRef.current) {
960
+ refreshQueuedRef.current = false;
961
+ await refreshOnce();
962
+ }
963
+ };
964
+
965
+ const inFlight = runLoop().finally(() => {
966
+ inFlightRefreshRef.current = null;
967
+ });
968
+ inFlightRefreshRef.current = inFlight;
969
+ await inFlight;
970
+ }, [refreshOnce]);
868
971
 
869
972
  useEffect(() => {
870
973
  void refresh();
@@ -1118,6 +1221,8 @@ export function createSyncularReact<
1118
1221
  watchTables = [],
1119
1222
  pollIntervalMs,
1120
1223
  staleAfterMs,
1224
+ transitionUpdates = true,
1225
+ onMetrics,
1121
1226
  } = options;
1122
1227
  const { db } = useSyncContext();
1123
1228
  const engine = useEngine();
@@ -1139,16 +1244,46 @@ export function createSyncularReact<
1139
1244
  const fingerprintCollectorRef = useRef(new FingerprintCollector());
1140
1245
  const previousFingerprintRef = useRef<string>('');
1141
1246
  const hasLoadedRef = useRef(false);
1247
+ const inFlightQueryRef = useRef<Promise<void> | null>(null);
1248
+ const queryQueuedRef = useRef(false);
1249
+ const metricsRef = useRef<UseSyncQueryMetrics>({
1250
+ executions: 0,
1251
+ coalescedRefreshes: 0,
1252
+ skippedDataUpdates: 0,
1253
+ lastDurationMs: null,
1254
+ });
1255
+ const onMetricsRef = useRef(onMetrics);
1256
+ onMetricsRef.current = onMetrics;
1257
+ const emitMetrics = useCallback(() => {
1258
+ onMetricsRef.current?.({ ...metricsRef.current });
1259
+ }, []);
1260
+ const applyUpdate = useCallback(
1261
+ (update: () => void) => {
1262
+ if (transitionUpdates) {
1263
+ startTransition(update);
1264
+ return;
1265
+ }
1266
+ update();
1267
+ },
1268
+ [transitionUpdates]
1269
+ );
1142
1270
 
1143
- const executeQuery = useCallback(async () => {
1271
+ const executeQueryOnce = useCallback(async () => {
1272
+ const startedAt = Date.now();
1273
+ metricsRef.current.executions += 1;
1144
1274
  if (!enabled) {
1145
1275
  if (previousFingerprintRef.current !== 'disabled') {
1146
1276
  previousFingerprintRef.current = 'disabled';
1147
- setData(undefined);
1148
1277
  }
1149
- setLastSyncAt(engine.getState().lastSyncAt);
1150
- setIsLoading(false);
1278
+ const snapshotLastSyncAt = engine.getState().lastSyncAt;
1279
+ applyUpdate(() => {
1280
+ setData(undefined);
1281
+ setLastSyncAt(snapshotLastSyncAt);
1282
+ setIsLoading(false);
1283
+ });
1151
1284
  hasLoadedRef.current = true;
1285
+ metricsRef.current.lastDurationMs = Date.now() - startedAt;
1286
+ emitMetrics();
1152
1287
  return;
1153
1288
  }
1154
1289
 
@@ -1176,52 +1311,88 @@ export function createSyncularReact<
1176
1311
 
1177
1312
  if (version === versionRef.current) {
1178
1313
  watchedScopesRef.current = scopeCollector;
1179
- setLastSyncAt(engine.getState().lastSyncAt);
1314
+ const snapshotLastSyncAt = engine.getState().lastSyncAt;
1180
1315
 
1181
1316
  const fingerprint = fingerprintCollectorRef.current.getCombined();
1182
- if (
1317
+ const didFingerprintChange =
1183
1318
  fingerprint !== previousFingerprintRef.current ||
1184
- fingerprint === ''
1185
- ) {
1319
+ fingerprint === '';
1320
+ if (didFingerprintChange) {
1186
1321
  previousFingerprintRef.current = fingerprint;
1187
- setData(result);
1322
+ } else {
1323
+ metricsRef.current.skippedDataUpdates += 1;
1188
1324
  }
1189
- setError(null);
1325
+
1326
+ applyUpdate(() => {
1327
+ setLastSyncAt(snapshotLastSyncAt);
1328
+ if (didFingerprintChange) {
1329
+ setData(result);
1330
+ }
1331
+ setError(null);
1332
+ });
1190
1333
  }
1191
1334
  } catch (err) {
1192
1335
  if (version === versionRef.current) {
1193
- setError(err instanceof Error ? err : new Error(String(err)));
1336
+ applyUpdate(() => {
1337
+ setError(err instanceof Error ? err : new Error(String(err)));
1338
+ });
1194
1339
  }
1195
1340
  } finally {
1196
1341
  if (version === versionRef.current) {
1197
- setIsLoading(false);
1342
+ applyUpdate(() => {
1343
+ setIsLoading(false);
1344
+ });
1198
1345
  hasLoadedRef.current = true;
1346
+ metricsRef.current.lastDurationMs = Date.now() - startedAt;
1347
+ emitMetrics();
1199
1348
  }
1200
1349
  }
1201
- }, [db, enabled, engine, keyField]);
1350
+ }, [db, enabled, engine, keyField, applyUpdate, emitMetrics]);
1351
+
1352
+ const executeQuery = useCallback(async () => {
1353
+ queryQueuedRef.current = true;
1354
+ if (inFlightQueryRef.current) {
1355
+ metricsRef.current.coalescedRefreshes += 1;
1356
+ emitMetrics();
1357
+ await inFlightQueryRef.current;
1358
+ return;
1359
+ }
1360
+
1361
+ const runLoop = async () => {
1362
+ while (queryQueuedRef.current) {
1363
+ queryQueuedRef.current = false;
1364
+ await executeQueryOnce();
1365
+ }
1366
+ };
1367
+
1368
+ const inFlight = runLoop().finally(() => {
1369
+ inFlightQueryRef.current = null;
1370
+ });
1371
+ inFlightQueryRef.current = inFlight;
1372
+ await inFlight;
1373
+ }, [executeQueryOnce, emitMetrics]);
1202
1374
 
1203
1375
  useEffect(() => {
1204
1376
  executeQuery();
1205
1377
  // eslint-disable-next-line react-hooks/exhaustive-deps
1206
1378
  }, [executeQuery, ...deps]);
1207
1379
 
1380
+ const getLastSyncAtSnapshot = useCallback(
1381
+ () => engine.getState().lastSyncAt,
1382
+ [engine]
1383
+ );
1208
1384
  useEffect(() => {
1209
- const unsubscribe = engine.subscribe(() => {
1210
- const nextLastSyncAt = engine.getState().lastSyncAt;
1211
- setLastSyncAt((previous) =>
1212
- previous === nextLastSyncAt ? previous : nextLastSyncAt
1213
- );
1214
- });
1215
- return unsubscribe;
1216
- }, [engine]);
1217
-
1218
- useEffect(() => {
1219
- if (!enabled) return;
1220
- const unsubscribe = engine.on('sync:complete', () => {
1221
- void executeQuery();
1222
- });
1385
+ const unsubscribe = engine.subscribeSelector(
1386
+ getLastSyncAtSnapshot,
1387
+ () => {
1388
+ const snapshotLastSyncAt = getLastSyncAtSnapshot();
1389
+ applyUpdate(() => {
1390
+ setLastSyncAt(snapshotLastSyncAt);
1391
+ });
1392
+ }
1393
+ );
1223
1394
  return unsubscribe;
1224
- }, [engine, enabled, executeQuery]);
1395
+ }, [engine, getLastSyncAtSnapshot, applyUpdate]);
1225
1396
 
1226
1397
  useEffect(() => {
1227
1398
  if (!enabled) return;
@@ -1609,9 +1780,10 @@ export function createSyncularReact<
1609
1780
  );
1610
1781
  }
1611
1782
 
1612
- function useOutbox(): UseOutboxResult {
1783
+ function useOutbox(options: UseOutboxOptions = {}): UseOutboxResult {
1613
1784
  const { db } = useSyncContext();
1614
1785
  const engine = useEngine();
1786
+ const { transitionUpdates = true, onMetrics } = options;
1615
1787
 
1616
1788
  const [stats, setStats] = useState<OutboxStats>({
1617
1789
  pending: 0,
@@ -1623,13 +1795,36 @@ export function createSyncularReact<
1623
1795
  const [pending, setPending] = useState<OutboxCommit[]>([]);
1624
1796
  const [failed, setFailed] = useState<OutboxCommit[]>([]);
1625
1797
  const [isLoading, setIsLoading] = useState(true);
1798
+ const inFlightRefreshRef = useRef<Promise<void> | null>(null);
1799
+ const refreshQueuedRef = useRef(false);
1800
+ const metricsRef = useRef<UseOutboxMetrics>({
1801
+ refreshes: 0,
1802
+ coalescedRefreshes: 0,
1803
+ lastDurationMs: null,
1804
+ });
1805
+ const onMetricsRef = useRef(onMetrics);
1806
+ onMetricsRef.current = onMetrics;
1807
+ const emitMetrics = useCallback(() => {
1808
+ onMetricsRef.current?.({ ...metricsRef.current });
1809
+ }, []);
1810
+ const applyUpdate = useCallback(
1811
+ (update: () => void) => {
1812
+ if (transitionUpdates) {
1813
+ startTransition(update);
1814
+ return;
1815
+ }
1816
+ update();
1817
+ },
1818
+ [transitionUpdates]
1819
+ );
1626
1820
 
1627
- const refresh = useCallback(async () => {
1821
+ const refreshOnce = useCallback(async () => {
1822
+ const startedAt = Date.now();
1823
+ metricsRef.current.refreshes += 1;
1628
1824
  try {
1629
1825
  setIsLoading(true);
1630
1826
 
1631
1827
  const newStats = await engine.refreshOutboxStats({ emit: false });
1632
- setStats(newStats);
1633
1828
 
1634
1829
  const rowsResult = await sql<{
1635
1830
  id: string;
@@ -1683,14 +1878,46 @@ export function createSyncularReact<
1683
1878
  };
1684
1879
  });
1685
1880
 
1686
- setPending(commits.filter((c) => c.status === 'pending'));
1687
- setFailed(commits.filter((c) => c.status === 'failed'));
1881
+ const nextPending = commits.filter((c) => c.status === 'pending');
1882
+ const nextFailed = commits.filter((c) => c.status === 'failed');
1883
+ applyUpdate(() => {
1884
+ setStats(newStats);
1885
+ setPending(nextPending);
1886
+ setFailed(nextFailed);
1887
+ });
1688
1888
  } catch (err) {
1689
1889
  console.error('[useOutbox] Failed to refresh:', err);
1690
1890
  } finally {
1691
- setIsLoading(false);
1891
+ applyUpdate(() => {
1892
+ setIsLoading(false);
1893
+ });
1894
+ metricsRef.current.lastDurationMs = Date.now() - startedAt;
1895
+ emitMetrics();
1692
1896
  }
1693
- }, [db, engine]);
1897
+ }, [db, engine, applyUpdate, emitMetrics]);
1898
+
1899
+ const refresh = useCallback(async () => {
1900
+ refreshQueuedRef.current = true;
1901
+ if (inFlightRefreshRef.current) {
1902
+ metricsRef.current.coalescedRefreshes += 1;
1903
+ emitMetrics();
1904
+ await inFlightRefreshRef.current;
1905
+ return;
1906
+ }
1907
+
1908
+ const runLoop = async () => {
1909
+ while (refreshQueuedRef.current) {
1910
+ refreshQueuedRef.current = false;
1911
+ await refreshOnce();
1912
+ }
1913
+ };
1914
+
1915
+ const inFlight = runLoop().finally(() => {
1916
+ inFlightRefreshRef.current = null;
1917
+ });
1918
+ inFlightRefreshRef.current = inFlight;
1919
+ await inFlight;
1920
+ }, [refreshOnce, emitMetrics]);
1694
1921
 
1695
1922
  useEffect(() => {
1696
1923
  refresh();
@@ -1703,13 +1930,6 @@ export function createSyncularReact<
1703
1930
  return unsubscribe;
1704
1931
  }, [engine, refresh]);
1705
1932
 
1706
- useEffect(() => {
1707
- const unsubscribe = engine.on('sync:complete', () => {
1708
- refresh();
1709
- });
1710
- return unsubscribe;
1711
- }, [engine, refresh]);
1712
-
1713
1933
  const hasUnsent = stats.pending > 0 || stats.failed > 0;
1714
1934
 
1715
1935
  const clearFailed = useCallback(async () => {
@@ -53,13 +53,9 @@ export function useSyncGroup<DB extends SyncClientDb = SyncClientDb>(args: {
53
53
 
54
54
  const subscribe = useCallback(
55
55
  (callback: () => void) => {
56
- const unsubs = channels.flatMap((channel) => [
57
- channel.engine.subscribe(callback),
58
- channel.engine.on('connection:change', callback),
59
- channel.engine.on('sync:complete', callback),
60
- channel.engine.on('sync:error', callback),
61
- channel.engine.on('outbox:change', callback),
62
- ]);
56
+ const unsubs = channels.map((channel) =>
57
+ channel.engine.subscribe(callback)
58
+ );
63
59
 
64
60
  return () => {
65
61
  for (const unsubscribe of unsubs) unsubscribe();