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

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.
@@ -15,6 +15,7 @@ import type {
15
15
  MutationsCommitFn,
16
16
  MutationsTx,
17
17
  OutboxCommitMeta,
18
+ PushResultInfo,
18
19
  SubscriptionState,
19
20
  SyncAwaitBootstrapOptions,
20
21
  SyncAwaitPhaseOptions,
@@ -54,6 +55,7 @@ import { type Kysely, sql } from 'kysely';
54
55
  import {
55
56
  createContext,
56
57
  type ReactNode,
58
+ startTransition,
57
59
  useCallback,
58
60
  useContext,
59
61
  useEffect,
@@ -151,7 +153,25 @@ export interface SyncProviderProps<
151
153
  realtimeFallbackPollMs?: number;
152
154
  onError?: (error: SyncError) => void;
153
155
  onConflict?: (conflict: ConflictInfo) => void;
156
+ onPushResult?: (result: PushResultInfo) => void;
154
157
  onDataChange?: (scopes: string[]) => void;
158
+ /**
159
+ * Debounce window (ms) for coalescing `data:change` events.
160
+ * - `0` (default): emit immediately
161
+ * - `>0`: merge scopes and emit once per window
162
+ */
163
+ dataChangeDebounceMs?: number;
164
+ /**
165
+ * Debounce override while sync is actively running.
166
+ * Falls back to `dataChangeDebounceMs` when omitted.
167
+ */
168
+ dataChangeDebounceMsWhenSyncing?: number;
169
+ /**
170
+ * Debounce override while connection is reconnecting.
171
+ * Falls back to `dataChangeDebounceMsWhenSyncing` (if syncing) and then
172
+ * `dataChangeDebounceMs` when omitted.
173
+ */
174
+ dataChangeDebounceMsWhenReconnecting?: number;
155
175
  plugins?: SyncClientPlugin[];
156
176
  /** Custom SHA-256 hash function (for platforms without crypto.subtle, e.g. React Native) */
157
177
  sha256?: (bytes: Uint8Array) => Promise<string>;
@@ -289,6 +309,22 @@ export interface UseConflictsResult {
289
309
  refresh: () => Promise<void>;
290
310
  }
291
311
 
312
+ export interface UseNewConflictsOptions {
313
+ /**
314
+ * Max number of buffered conflict notifications kept in memory.
315
+ * Oldest notifications are dropped once the buffer reaches this size.
316
+ */
317
+ maxBuffered?: number;
318
+ }
319
+
320
+ export interface UseNewConflictsResult {
321
+ conflicts: ConflictInfo[];
322
+ latest: ConflictInfo | null;
323
+ count: number;
324
+ clear: () => void;
325
+ dismiss: (conflictId: string) => void;
326
+ }
327
+
292
328
  export type ConflictResolution = 'accept' | 'reject' | 'merge';
293
329
 
294
330
  export interface UseResolveConflictResult {
@@ -317,6 +353,13 @@ export interface UseSyncQueryResult<T> {
317
353
  refetch: () => Promise<void>;
318
354
  }
319
355
 
356
+ interface UseSyncQueryMetrics {
357
+ executions: number;
358
+ coalescedRefreshes: number;
359
+ skippedDataUpdates: number;
360
+ lastDurationMs: number | null;
361
+ }
362
+
320
363
  export interface UseSyncQueryOptions {
321
364
  enabled?: boolean;
322
365
  deps?: unknown[];
@@ -324,6 +367,15 @@ export interface UseSyncQueryOptions {
324
367
  watchTables?: string[];
325
368
  pollIntervalMs?: number;
326
369
  staleAfterMs?: number;
370
+ /**
371
+ * If true (default), non-urgent hook state updates are scheduled in
372
+ * `startTransition` to keep UI interactions responsive under bursty sync.
373
+ */
374
+ transitionUpdates?: boolean;
375
+ /**
376
+ * Optional low-overhead instrumentation callback for query executions.
377
+ */
378
+ onMetrics?: (metrics: UseSyncQueryMetrics) => void;
327
379
  }
328
380
 
329
381
  export interface UseQueryResult<T> {
@@ -428,6 +480,24 @@ export interface UseOutboxResult {
428
480
  clearAll: () => Promise<number>;
429
481
  }
430
482
 
483
+ interface UseOutboxMetrics {
484
+ refreshes: number;
485
+ coalescedRefreshes: number;
486
+ lastDurationMs: number | null;
487
+ }
488
+
489
+ interface UseOutboxOptions {
490
+ /**
491
+ * If true (default), non-urgent outbox state updates are scheduled in
492
+ * `startTransition` to reduce render contention during sync bursts.
493
+ */
494
+ transitionUpdates?: boolean;
495
+ /**
496
+ * Optional low-overhead instrumentation callback for outbox refreshes.
497
+ */
498
+ onMetrics?: (metrics: UseOutboxMetrics) => void;
499
+ }
500
+
431
501
  export interface UsePresenceResult<TMetadata = Record<string, unknown>> {
432
502
  presence: PresenceEntry<TMetadata>[];
433
503
  isLoading: boolean;
@@ -472,7 +542,11 @@ export function createSyncularReact<
472
542
  realtimeFallbackPollMs,
473
543
  onError,
474
544
  onConflict,
545
+ onPushResult,
475
546
  onDataChange,
547
+ dataChangeDebounceMs,
548
+ dataChangeDebounceMsWhenSyncing,
549
+ dataChangeDebounceMsWhenReconnecting,
476
550
  plugins,
477
551
  sha256,
478
552
  autoStart = true,
@@ -504,7 +578,11 @@ export function createSyncularReact<
504
578
  realtimeFallbackPollMs,
505
579
  onError,
506
580
  onConflict,
581
+ onPushResult,
507
582
  onDataChange,
583
+ dataChangeDebounceMs,
584
+ dataChangeDebounceMsWhenSyncing,
585
+ dataChangeDebounceMsWhenReconnecting,
508
586
  plugins,
509
587
  sha256,
510
588
  }),
@@ -527,7 +605,11 @@ export function createSyncularReact<
527
605
  realtimeFallbackPollMs,
528
606
  onError,
529
607
  onConflict,
608
+ onPushResult,
530
609
  onDataChange,
610
+ dataChangeDebounceMs,
611
+ dataChangeDebounceMsWhenSyncing,
612
+ dataChangeDebounceMsWhenReconnecting,
531
613
  plugins,
532
614
  sha256,
533
615
  ]
@@ -793,23 +875,19 @@ export function createSyncularReact<
793
875
  function useTransportHealth(): UseTransportHealthResult {
794
876
  const engine = useEngine();
795
877
 
878
+ const getSnapshot = useCallback(
879
+ () => engine.getTransportHealth(),
880
+ [engine]
881
+ );
796
882
  const health = useSyncExternalStore(
797
883
  useCallback(
798
884
  (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
- };
885
+ return engine.subscribeSelector(getSnapshot, callback);
808
886
  },
809
- [engine]
887
+ [engine, getSnapshot]
810
888
  ),
811
- useCallback(() => engine.getTransportHealth(), [engine]),
812
- useCallback(() => engine.getTransportHealth(), [engine])
889
+ getSnapshot,
890
+ getSnapshot
813
891
  );
814
892
 
815
893
  return useMemo(() => ({ health }), [health]);
@@ -823,6 +901,7 @@ export function createSyncularReact<
823
901
  refreshOn: readonly SyncEngineEventName[];
824
902
  pollIntervalMs?: number;
825
903
  shouldPoll?: (value: T) => boolean;
904
+ transitionUpdates?: boolean;
826
905
  }): {
827
906
  value: T;
828
907
  isLoading: boolean;
@@ -830,8 +909,14 @@ export function createSyncularReact<
830
909
  refresh: () => Promise<void>;
831
910
  } {
832
911
  const engine = useEngine();
833
- const { initialValue, load, refreshOn, pollIntervalMs, shouldPoll } =
834
- options;
912
+ const {
913
+ initialValue,
914
+ load,
915
+ refreshOn,
916
+ pollIntervalMs,
917
+ shouldPoll,
918
+ transitionUpdates = true,
919
+ } = options;
835
920
 
836
921
  const loadRef = useRef(load);
837
922
  loadRef.current = load;
@@ -841,8 +926,20 @@ export function createSyncularReact<
841
926
  const [error, setError] = useState<Error | null>(null);
842
927
  const loadedRef = useRef(false);
843
928
  const versionRef = useRef(0);
929
+ const inFlightRefreshRef = useRef<Promise<void> | null>(null);
930
+ const refreshQueuedRef = useRef(false);
931
+ const applyUpdate = useCallback(
932
+ (update: () => void) => {
933
+ if (transitionUpdates) {
934
+ startTransition(update);
935
+ return;
936
+ }
937
+ update();
938
+ },
939
+ [transitionUpdates]
940
+ );
844
941
 
845
- const refresh = useCallback(async () => {
942
+ const refreshOnce = useCallback(async () => {
846
943
  const version = ++versionRef.current;
847
944
  const isCurrent = () => version === versionRef.current;
848
945
 
@@ -853,18 +950,45 @@ export function createSyncularReact<
853
950
  try {
854
951
  const next = await loadRef.current();
855
952
  if (!isCurrent()) return;
856
- setValue(next);
857
- setError(null);
953
+ applyUpdate(() => {
954
+ setValue(next);
955
+ setError(null);
956
+ });
858
957
  } catch (err) {
859
958
  if (!isCurrent()) return;
860
- setError(err instanceof Error ? err : new Error(String(err)));
959
+ applyUpdate(() => {
960
+ setError(err instanceof Error ? err : new Error(String(err)));
961
+ });
861
962
  } finally {
862
963
  if (isCurrent()) {
863
964
  loadedRef.current = true;
864
- setIsLoading(false);
965
+ applyUpdate(() => {
966
+ setIsLoading(false);
967
+ });
865
968
  }
866
969
  }
867
- }, []);
970
+ }, [applyUpdate]);
971
+
972
+ const refresh = useCallback(async () => {
973
+ refreshQueuedRef.current = true;
974
+ if (inFlightRefreshRef.current) {
975
+ await inFlightRefreshRef.current;
976
+ return;
977
+ }
978
+
979
+ const runLoop = async () => {
980
+ while (refreshQueuedRef.current) {
981
+ refreshQueuedRef.current = false;
982
+ await refreshOnce();
983
+ }
984
+ };
985
+
986
+ const inFlight = runLoop().finally(() => {
987
+ inFlightRefreshRef.current = null;
988
+ });
989
+ inFlightRefreshRef.current = inFlight;
990
+ await inFlight;
991
+ }, [refreshOnce]);
868
992
 
869
993
  useEffect(() => {
870
994
  void refresh();
@@ -1021,7 +1145,7 @@ export function createSyncularReact<
1021
1145
  } = useAsyncEngineResource<ConflictInfo[]>({
1022
1146
  initialValue: [],
1023
1147
  load: () => engine.getConflicts(),
1024
- refreshOn: ['sync:complete', 'sync:error'],
1148
+ refreshOn: ['sync:complete', 'sync:error', 'conflict:new'],
1025
1149
  });
1026
1150
 
1027
1151
  return useMemo(
@@ -1036,6 +1160,50 @@ export function createSyncularReact<
1036
1160
  );
1037
1161
  }
1038
1162
 
1163
+ function useNewConflicts(
1164
+ options: UseNewConflictsOptions = {}
1165
+ ): UseNewConflictsResult {
1166
+ const engine = useEngine();
1167
+ const maxBuffered = Math.max(1, Math.min(500, options.maxBuffered ?? 100));
1168
+ const [conflicts, setConflicts] = useState<ConflictInfo[]>([]);
1169
+
1170
+ useEffect(() => {
1171
+ setConflicts([]);
1172
+ const unsubscribe = engine.on('conflict:new', (conflict) => {
1173
+ setConflicts((previous) => {
1174
+ if (previous.some((item) => item.id === conflict.id)) {
1175
+ return previous;
1176
+ }
1177
+ const next = [...previous, conflict];
1178
+ const overflow = next.length - maxBuffered;
1179
+ return overflow > 0 ? next.slice(overflow) : next;
1180
+ });
1181
+ });
1182
+ return unsubscribe;
1183
+ }, [engine, maxBuffered]);
1184
+
1185
+ const clear = useCallback(() => {
1186
+ setConflicts([]);
1187
+ }, []);
1188
+
1189
+ const dismiss = useCallback((conflictId: string) => {
1190
+ setConflicts((previous) =>
1191
+ previous.filter((conflict) => conflict.id !== conflictId)
1192
+ );
1193
+ }, []);
1194
+
1195
+ return useMemo(
1196
+ () => ({
1197
+ conflicts,
1198
+ latest: conflicts[conflicts.length - 1] ?? null,
1199
+ count: conflicts.length,
1200
+ clear,
1201
+ dismiss,
1202
+ }),
1203
+ [conflicts, clear, dismiss]
1204
+ );
1205
+ }
1206
+
1039
1207
  function useResolveConflict(
1040
1208
  options: UseResolveConflictOptions = {}
1041
1209
  ): UseResolveConflictResult {
@@ -1118,6 +1286,8 @@ export function createSyncularReact<
1118
1286
  watchTables = [],
1119
1287
  pollIntervalMs,
1120
1288
  staleAfterMs,
1289
+ transitionUpdates = true,
1290
+ onMetrics,
1121
1291
  } = options;
1122
1292
  const { db } = useSyncContext();
1123
1293
  const engine = useEngine();
@@ -1139,16 +1309,46 @@ export function createSyncularReact<
1139
1309
  const fingerprintCollectorRef = useRef(new FingerprintCollector());
1140
1310
  const previousFingerprintRef = useRef<string>('');
1141
1311
  const hasLoadedRef = useRef(false);
1312
+ const inFlightQueryRef = useRef<Promise<void> | null>(null);
1313
+ const queryQueuedRef = useRef(false);
1314
+ const metricsRef = useRef<UseSyncQueryMetrics>({
1315
+ executions: 0,
1316
+ coalescedRefreshes: 0,
1317
+ skippedDataUpdates: 0,
1318
+ lastDurationMs: null,
1319
+ });
1320
+ const onMetricsRef = useRef(onMetrics);
1321
+ onMetricsRef.current = onMetrics;
1322
+ const emitMetrics = useCallback(() => {
1323
+ onMetricsRef.current?.({ ...metricsRef.current });
1324
+ }, []);
1325
+ const applyUpdate = useCallback(
1326
+ (update: () => void) => {
1327
+ if (transitionUpdates) {
1328
+ startTransition(update);
1329
+ return;
1330
+ }
1331
+ update();
1332
+ },
1333
+ [transitionUpdates]
1334
+ );
1142
1335
 
1143
- const executeQuery = useCallback(async () => {
1336
+ const executeQueryOnce = useCallback(async () => {
1337
+ const startedAt = Date.now();
1338
+ metricsRef.current.executions += 1;
1144
1339
  if (!enabled) {
1145
1340
  if (previousFingerprintRef.current !== 'disabled') {
1146
1341
  previousFingerprintRef.current = 'disabled';
1147
- setData(undefined);
1148
1342
  }
1149
- setLastSyncAt(engine.getState().lastSyncAt);
1150
- setIsLoading(false);
1343
+ const snapshotLastSyncAt = engine.getState().lastSyncAt;
1344
+ applyUpdate(() => {
1345
+ setData(undefined);
1346
+ setLastSyncAt(snapshotLastSyncAt);
1347
+ setIsLoading(false);
1348
+ });
1151
1349
  hasLoadedRef.current = true;
1350
+ metricsRef.current.lastDurationMs = Date.now() - startedAt;
1351
+ emitMetrics();
1152
1352
  return;
1153
1353
  }
1154
1354
 
@@ -1176,52 +1376,88 @@ export function createSyncularReact<
1176
1376
 
1177
1377
  if (version === versionRef.current) {
1178
1378
  watchedScopesRef.current = scopeCollector;
1179
- setLastSyncAt(engine.getState().lastSyncAt);
1379
+ const snapshotLastSyncAt = engine.getState().lastSyncAt;
1180
1380
 
1181
1381
  const fingerprint = fingerprintCollectorRef.current.getCombined();
1182
- if (
1382
+ const didFingerprintChange =
1183
1383
  fingerprint !== previousFingerprintRef.current ||
1184
- fingerprint === ''
1185
- ) {
1384
+ fingerprint === '';
1385
+ if (didFingerprintChange) {
1186
1386
  previousFingerprintRef.current = fingerprint;
1187
- setData(result);
1387
+ } else {
1388
+ metricsRef.current.skippedDataUpdates += 1;
1188
1389
  }
1189
- setError(null);
1390
+
1391
+ applyUpdate(() => {
1392
+ setLastSyncAt(snapshotLastSyncAt);
1393
+ if (didFingerprintChange) {
1394
+ setData(result);
1395
+ }
1396
+ setError(null);
1397
+ });
1190
1398
  }
1191
1399
  } catch (err) {
1192
1400
  if (version === versionRef.current) {
1193
- setError(err instanceof Error ? err : new Error(String(err)));
1401
+ applyUpdate(() => {
1402
+ setError(err instanceof Error ? err : new Error(String(err)));
1403
+ });
1194
1404
  }
1195
1405
  } finally {
1196
1406
  if (version === versionRef.current) {
1197
- setIsLoading(false);
1407
+ applyUpdate(() => {
1408
+ setIsLoading(false);
1409
+ });
1198
1410
  hasLoadedRef.current = true;
1411
+ metricsRef.current.lastDurationMs = Date.now() - startedAt;
1412
+ emitMetrics();
1199
1413
  }
1200
1414
  }
1201
- }, [db, enabled, engine, keyField]);
1415
+ }, [db, enabled, engine, keyField, applyUpdate, emitMetrics]);
1416
+
1417
+ const executeQuery = useCallback(async () => {
1418
+ queryQueuedRef.current = true;
1419
+ if (inFlightQueryRef.current) {
1420
+ metricsRef.current.coalescedRefreshes += 1;
1421
+ emitMetrics();
1422
+ await inFlightQueryRef.current;
1423
+ return;
1424
+ }
1425
+
1426
+ const runLoop = async () => {
1427
+ while (queryQueuedRef.current) {
1428
+ queryQueuedRef.current = false;
1429
+ await executeQueryOnce();
1430
+ }
1431
+ };
1432
+
1433
+ const inFlight = runLoop().finally(() => {
1434
+ inFlightQueryRef.current = null;
1435
+ });
1436
+ inFlightQueryRef.current = inFlight;
1437
+ await inFlight;
1438
+ }, [executeQueryOnce, emitMetrics]);
1202
1439
 
1203
1440
  useEffect(() => {
1204
1441
  executeQuery();
1205
1442
  // eslint-disable-next-line react-hooks/exhaustive-deps
1206
1443
  }, [executeQuery, ...deps]);
1207
1444
 
1445
+ const getLastSyncAtSnapshot = useCallback(
1446
+ () => engine.getState().lastSyncAt,
1447
+ [engine]
1448
+ );
1208
1449
  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
- });
1450
+ const unsubscribe = engine.subscribeSelector(
1451
+ getLastSyncAtSnapshot,
1452
+ () => {
1453
+ const snapshotLastSyncAt = getLastSyncAtSnapshot();
1454
+ applyUpdate(() => {
1455
+ setLastSyncAt(snapshotLastSyncAt);
1456
+ });
1457
+ }
1458
+ );
1223
1459
  return unsubscribe;
1224
- }, [engine, enabled, executeQuery]);
1460
+ }, [engine, getLastSyncAtSnapshot, applyUpdate]);
1225
1461
 
1226
1462
  useEffect(() => {
1227
1463
  if (!enabled) return;
@@ -1609,9 +1845,10 @@ export function createSyncularReact<
1609
1845
  );
1610
1846
  }
1611
1847
 
1612
- function useOutbox(): UseOutboxResult {
1848
+ function useOutbox(options: UseOutboxOptions = {}): UseOutboxResult {
1613
1849
  const { db } = useSyncContext();
1614
1850
  const engine = useEngine();
1851
+ const { transitionUpdates = true, onMetrics } = options;
1615
1852
 
1616
1853
  const [stats, setStats] = useState<OutboxStats>({
1617
1854
  pending: 0,
@@ -1623,13 +1860,36 @@ export function createSyncularReact<
1623
1860
  const [pending, setPending] = useState<OutboxCommit[]>([]);
1624
1861
  const [failed, setFailed] = useState<OutboxCommit[]>([]);
1625
1862
  const [isLoading, setIsLoading] = useState(true);
1863
+ const inFlightRefreshRef = useRef<Promise<void> | null>(null);
1864
+ const refreshQueuedRef = useRef(false);
1865
+ const metricsRef = useRef<UseOutboxMetrics>({
1866
+ refreshes: 0,
1867
+ coalescedRefreshes: 0,
1868
+ lastDurationMs: null,
1869
+ });
1870
+ const onMetricsRef = useRef(onMetrics);
1871
+ onMetricsRef.current = onMetrics;
1872
+ const emitMetrics = useCallback(() => {
1873
+ onMetricsRef.current?.({ ...metricsRef.current });
1874
+ }, []);
1875
+ const applyUpdate = useCallback(
1876
+ (update: () => void) => {
1877
+ if (transitionUpdates) {
1878
+ startTransition(update);
1879
+ return;
1880
+ }
1881
+ update();
1882
+ },
1883
+ [transitionUpdates]
1884
+ );
1626
1885
 
1627
- const refresh = useCallback(async () => {
1886
+ const refreshOnce = useCallback(async () => {
1887
+ const startedAt = Date.now();
1888
+ metricsRef.current.refreshes += 1;
1628
1889
  try {
1629
1890
  setIsLoading(true);
1630
1891
 
1631
1892
  const newStats = await engine.refreshOutboxStats({ emit: false });
1632
- setStats(newStats);
1633
1893
 
1634
1894
  const rowsResult = await sql<{
1635
1895
  id: string;
@@ -1683,14 +1943,46 @@ export function createSyncularReact<
1683
1943
  };
1684
1944
  });
1685
1945
 
1686
- setPending(commits.filter((c) => c.status === 'pending'));
1687
- setFailed(commits.filter((c) => c.status === 'failed'));
1946
+ const nextPending = commits.filter((c) => c.status === 'pending');
1947
+ const nextFailed = commits.filter((c) => c.status === 'failed');
1948
+ applyUpdate(() => {
1949
+ setStats(newStats);
1950
+ setPending(nextPending);
1951
+ setFailed(nextFailed);
1952
+ });
1688
1953
  } catch (err) {
1689
1954
  console.error('[useOutbox] Failed to refresh:', err);
1690
1955
  } finally {
1691
- setIsLoading(false);
1956
+ applyUpdate(() => {
1957
+ setIsLoading(false);
1958
+ });
1959
+ metricsRef.current.lastDurationMs = Date.now() - startedAt;
1960
+ emitMetrics();
1692
1961
  }
1693
- }, [db, engine]);
1962
+ }, [db, engine, applyUpdate, emitMetrics]);
1963
+
1964
+ const refresh = useCallback(async () => {
1965
+ refreshQueuedRef.current = true;
1966
+ if (inFlightRefreshRef.current) {
1967
+ metricsRef.current.coalescedRefreshes += 1;
1968
+ emitMetrics();
1969
+ await inFlightRefreshRef.current;
1970
+ return;
1971
+ }
1972
+
1973
+ const runLoop = async () => {
1974
+ while (refreshQueuedRef.current) {
1975
+ refreshQueuedRef.current = false;
1976
+ await refreshOnce();
1977
+ }
1978
+ };
1979
+
1980
+ const inFlight = runLoop().finally(() => {
1981
+ inFlightRefreshRef.current = null;
1982
+ });
1983
+ inFlightRefreshRef.current = inFlight;
1984
+ await inFlight;
1985
+ }, [refreshOnce, emitMetrics]);
1694
1986
 
1695
1987
  useEffect(() => {
1696
1988
  refresh();
@@ -1703,13 +1995,6 @@ export function createSyncularReact<
1703
1995
  return unsubscribe;
1704
1996
  }, [engine, refresh]);
1705
1997
 
1706
- useEffect(() => {
1707
- const unsubscribe = engine.on('sync:complete', () => {
1708
- refresh();
1709
- });
1710
- return unsubscribe;
1711
- }, [engine, refresh]);
1712
-
1713
1998
  const hasUnsent = stats.pending > 0 || stats.failed > 0;
1714
1999
 
1715
2000
  const clearFailed = useCallback(async () => {
@@ -1874,6 +2159,7 @@ export function createSyncularReact<
1874
2159
  useMutations,
1875
2160
  useOutbox,
1876
2161
  useConflicts,
2162
+ useNewConflicts,
1877
2163
  useResolveConflict,
1878
2164
  usePresence,
1879
2165
  usePresenceWithJoin,
package/src/index.ts CHANGED
@@ -21,6 +21,8 @@ export type {
21
21
  UseMutationOptions,
22
22
  UseMutationResult,
23
23
  UseMutationsOptions,
24
+ UseNewConflictsOptions,
25
+ UseNewConflictsResult,
24
26
  UseOutboxResult,
25
27
  UsePresenceResult,
26
28
  UsePresenceWithJoinOptions,
@@ -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();