@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.
- package/dist/createSyncularReact.d.ts +67 -3
- package/dist/createSyncularReact.d.ts.map +1 -1
- package/dist/createSyncularReact.js +226 -59
- package/dist/createSyncularReact.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.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__/SyncEngine.test.ts +135 -0
- package/src/__tests__/hooks.test.tsx +190 -0
- package/src/createSyncularReact.tsx +349 -63
- package/src/index.ts +2 -0
- package/src/useSyncGroup.ts +3 -7
|
@@ -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
|
-
|
|
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
|
-
|
|
812
|
-
|
|
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 {
|
|
834
|
-
|
|
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
|
|
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
|
-
|
|
857
|
-
|
|
953
|
+
applyUpdate(() => {
|
|
954
|
+
setValue(next);
|
|
955
|
+
setError(null);
|
|
956
|
+
});
|
|
858
957
|
} catch (err) {
|
|
859
958
|
if (!isCurrent()) return;
|
|
860
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1150
|
-
|
|
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
|
-
|
|
1379
|
+
const snapshotLastSyncAt = engine.getState().lastSyncAt;
|
|
1180
1380
|
|
|
1181
1381
|
const fingerprint = fingerprintCollectorRef.current.getCombined();
|
|
1182
|
-
|
|
1382
|
+
const didFingerprintChange =
|
|
1183
1383
|
fingerprint !== previousFingerprintRef.current ||
|
|
1184
|
-
fingerprint === ''
|
|
1185
|
-
) {
|
|
1384
|
+
fingerprint === '';
|
|
1385
|
+
if (didFingerprintChange) {
|
|
1186
1386
|
previousFingerprintRef.current = fingerprint;
|
|
1187
|
-
|
|
1387
|
+
} else {
|
|
1388
|
+
metricsRef.current.skippedDataUpdates += 1;
|
|
1188
1389
|
}
|
|
1189
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
});
|
|
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,
|
|
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
|
|
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
|
-
|
|
1687
|
-
|
|
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
|
-
|
|
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
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();
|