@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.
- package/dist/createSyncularReact.d.ts +50 -2
- package/dist/createSyncularReact.d.ts.map +1 -1
- package/dist/createSyncularReact.js +190 -58
- package/dist/createSyncularReact.js.map +1 -1
- package/dist/useSyncGroup.d.ts.map +1 -1
- package/dist/useSyncGroup.js +1 -7
- package/dist/useSyncGroup.js.map +1 -1
- package/package.json +5 -5
- package/src/__tests__/hooks.test.tsx +130 -0
- package/src/createSyncularReact.tsx +282 -62
- package/src/useSyncGroup.ts +3 -7
|
@@ -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
|
-
|
|
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
|
-
|
|
812
|
-
|
|
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 {
|
|
834
|
-
|
|
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
|
|
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
|
-
|
|
857
|
-
|
|
932
|
+
applyUpdate(() => {
|
|
933
|
+
setValue(next);
|
|
934
|
+
setError(null);
|
|
935
|
+
});
|
|
858
936
|
} catch (err) {
|
|
859
937
|
if (!isCurrent()) return;
|
|
860
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1150
|
-
|
|
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
|
-
|
|
1314
|
+
const snapshotLastSyncAt = engine.getState().lastSyncAt;
|
|
1180
1315
|
|
|
1181
1316
|
const fingerprint = fingerprintCollectorRef.current.getCombined();
|
|
1182
|
-
|
|
1317
|
+
const didFingerprintChange =
|
|
1183
1318
|
fingerprint !== previousFingerprintRef.current ||
|
|
1184
|
-
fingerprint === ''
|
|
1185
|
-
) {
|
|
1319
|
+
fingerprint === '';
|
|
1320
|
+
if (didFingerprintChange) {
|
|
1186
1321
|
previousFingerprintRef.current = fingerprint;
|
|
1187
|
-
|
|
1322
|
+
} else {
|
|
1323
|
+
metricsRef.current.skippedDataUpdates += 1;
|
|
1188
1324
|
}
|
|
1189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
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,
|
|
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
|
|
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
|
-
|
|
1687
|
-
|
|
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
|
-
|
|
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 () => {
|
package/src/useSyncGroup.ts
CHANGED
|
@@ -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.
|
|
57
|
-
channel.engine.subscribe(callback)
|
|
58
|
-
|
|
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();
|