@firtoz/collection-sync 1.0.0

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.
@@ -0,0 +1,230 @@
1
+ import {
2
+ useEffectEvent,
3
+ useLayoutEffect,
4
+ useMemo,
5
+ useRef,
6
+ useSyncExternalStore,
7
+ } from "react";
8
+ import type { PartialSyncReconcileResult } from "../partial-sync-client-bridge";
9
+ import type { SyncRange } from "../sync-protocol";
10
+ import {
11
+ DEFAULT_VIEWPORT_RANGE_MAX_WAIT_MS,
12
+ DEFAULT_VIEWPORT_RANGE_QUIET_MS,
13
+ } from "./constants";
14
+ import { usePredicateFilteredRows } from "./usePredicateFilteredRows";
15
+ import type {
16
+ PartialSyncItem,
17
+ UsePartialSyncViewportOptions,
18
+ UsePartialSyncViewportResult,
19
+ } from "./types";
20
+
21
+ /**
22
+ * Predicate viewport sync: debounced server `rangeQuery` for a moving logical viewport plus
23
+ * {@link usePredicateFilteredRows} for the visible rows already in the local collection.
24
+ */
25
+ export function usePartialSyncViewport<
26
+ TItem extends PartialSyncItem,
27
+ TViewport,
28
+ TSortColumn extends keyof TItem & string,
29
+ >({
30
+ bridge,
31
+ bridgeState,
32
+ collection,
33
+ adapter,
34
+ viewport,
35
+ predicateLimit,
36
+ prefetchPad = 0,
37
+ quietMs = DEFAULT_VIEWPORT_RANGE_QUIET_MS,
38
+ maxWaitMs = DEFAULT_VIEWPORT_RANGE_MAX_WAIT_MS,
39
+ totalCountFallback = 0,
40
+ getColumnValue,
41
+ cacheDisplayMode = "immediate",
42
+ alwaysIncludeRowIds,
43
+ onRangeReconcile,
44
+ }: UsePartialSyncViewportOptions<
45
+ TItem,
46
+ TViewport,
47
+ TSortColumn
48
+ >): UsePartialSyncViewportResult<TItem> {
49
+ const conditions = useMemo(
50
+ () => adapter.toConditions(viewport),
51
+ [adapter, viewport],
52
+ );
53
+
54
+ const confirmedKeysRevision = useSyncExternalStore(
55
+ (onStoreChange) => bridge.subscribeConfirmedKeysRevision(onStoreChange),
56
+ () => bridge.serverConfirmedKeysRevision,
57
+ () => 0,
58
+ );
59
+
60
+ const viewportRows = usePredicateFilteredRows({
61
+ collection,
62
+ conditions,
63
+ sort: adapter.sort,
64
+ getSortValue: adapter.getSortValue,
65
+ ...(getColumnValue !== undefined ? { getColumnValue } : {}),
66
+ limit: predicateLimit,
67
+ cacheDisplayMode,
68
+ ...(alwaysIncludeRowIds !== undefined && alwaysIncludeRowIds.length > 0
69
+ ? { alwaysIncludeRowIds }
70
+ : {}),
71
+ ...(cacheDisplayMode === "confirmed"
72
+ ? {
73
+ confirmedRowKeys: bridge.serverConfirmedKeys,
74
+ confirmedKeysRevision,
75
+ }
76
+ : {}),
77
+ });
78
+
79
+ const fetchViewport = useMemo(
80
+ () => adapter.expandViewport(viewport, prefetchPad),
81
+ [adapter, prefetchPad, viewport],
82
+ );
83
+
84
+ const bridgeRef = useRef(bridge);
85
+ bridgeRef.current = bridge;
86
+ const adapterRef = useRef(adapter);
87
+ adapterRef.current = adapter;
88
+ const fetchViewportRef = useRef(fetchViewport);
89
+ fetchViewportRef.current = fetchViewport;
90
+ const predicateLimitRef = useRef(predicateLimit);
91
+ predicateLimitRef.current = predicateLimit;
92
+
93
+ const quietTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
94
+ const maxWaitTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
95
+ const lastRangeFetchAtRef = useRef(0);
96
+
97
+ const onRangeReconcileEvent = useEffectEvent(
98
+ (rec: PartialSyncReconcileResult<TItem>) => {
99
+ onRangeReconcile?.(rec);
100
+ },
101
+ );
102
+
103
+ const canReconcileWithManifest = useEffectEvent((): boolean => {
104
+ const col = collection;
105
+ if (typeof col.get !== "function") return false;
106
+ const keys = bridge.serverConfirmedKeys;
107
+ if (keys.size === 0) return false;
108
+ for (const k of keys) {
109
+ let row = col.get(k);
110
+ if (row === undefined && typeof k === "number") {
111
+ row = col.get(String(k));
112
+ } else if (row === undefined && typeof k === "string") {
113
+ const n = Number(k);
114
+ if (!Number.isNaN(n)) row = col.get(n);
115
+ }
116
+ if (row !== undefined) return true;
117
+ }
118
+ return false;
119
+ });
120
+
121
+ useLayoutEffect(() => {
122
+ const clearQuietTimer = () => {
123
+ if (quietTimerRef.current !== null) {
124
+ clearTimeout(quietTimerRef.current);
125
+ quietTimerRef.current = null;
126
+ }
127
+ };
128
+ const clearMaxWaitTimer = () => {
129
+ if (maxWaitTimerRef.current !== null) {
130
+ clearTimeout(maxWaitTimerRef.current);
131
+ maxWaitTimerRef.current = null;
132
+ }
133
+ };
134
+
135
+ const runRangeQuery = () => {
136
+ clearQuietTimer();
137
+ clearMaxWaitTimer();
138
+ lastRangeFetchAtRef.current = performance.now();
139
+ const v = fetchViewportRef.current;
140
+ const ad = adapterRef.current;
141
+ const range: SyncRange = {
142
+ kind: "predicate",
143
+ conditions: ad.toConditions(v),
144
+ sort: ad.sort,
145
+ limit: predicateLimitRef.current,
146
+ };
147
+ if (canReconcileWithManifest()) {
148
+ void bridgeRef.current
149
+ .requestRangeReconcile(range)
150
+ .then((rec) => {
151
+ onRangeReconcileEvent(rec);
152
+ })
153
+ .catch((err: unknown) => {
154
+ console.error("partial sync viewport rangeReconcile failed", err);
155
+ });
156
+ return;
157
+ }
158
+ void bridgeRef.current.requestRangeQuery(range).catch((err: unknown) => {
159
+ console.error("partial sync viewport rangeQuery failed", err);
160
+ });
161
+ };
162
+
163
+ clearQuietTimer();
164
+ clearMaxWaitTimer();
165
+
166
+ const now = performance.now();
167
+ const sinceLastFetch = now - lastRangeFetchAtRef.current;
168
+ const fetchImmediately =
169
+ lastRangeFetchAtRef.current === 0 || sinceLastFetch >= maxWaitMs;
170
+
171
+ if (fetchImmediately) {
172
+ runRangeQuery();
173
+ return () => {
174
+ clearQuietTimer();
175
+ clearMaxWaitTimer();
176
+ };
177
+ }
178
+
179
+ quietTimerRef.current = setTimeout(runRangeQuery, quietMs);
180
+ maxWaitTimerRef.current = setTimeout(
181
+ runRangeQuery,
182
+ Math.max(0, maxWaitMs - sinceLastFetch),
183
+ );
184
+
185
+ return () => {
186
+ clearQuietTimer();
187
+ clearMaxWaitTimer();
188
+ };
189
+ }, [conditions, prefetchPad, maxWaitMs, quietMs]);
190
+
191
+ useLayoutEffect(() => {
192
+ if (typeof document === "undefined") return;
193
+ const onVisibleRefresh = (): void => {
194
+ if (document.visibilityState !== "visible") return;
195
+ const v = fetchViewportRef.current;
196
+ const ad = adapterRef.current;
197
+ const range: SyncRange = {
198
+ kind: "predicate",
199
+ conditions: ad.toConditions(v),
200
+ sort: ad.sort,
201
+ limit: predicateLimitRef.current,
202
+ };
203
+ if (canReconcileWithManifest()) {
204
+ void bridgeRef.current
205
+ .requestRangeReconcile(range)
206
+ .then((rec) => {
207
+ onRangeReconcileEvent(rec);
208
+ })
209
+ .catch((err: unknown) => {
210
+ console.error("partial sync viewport rangeReconcile failed", err);
211
+ });
212
+ return;
213
+ }
214
+ void bridgeRef.current.requestRangeQuery(range).catch((err: unknown) => {
215
+ console.error("partial sync viewport rangeQuery failed", err);
216
+ });
217
+ };
218
+ document.addEventListener("visibilitychange", onVisibleRefresh);
219
+ return () => {
220
+ document.removeEventListener("visibilitychange", onVisibleRefresh);
221
+ };
222
+ }, []);
223
+
224
+ const totalCount =
225
+ bridgeState.status === "partial" || bridgeState.status === "realtime"
226
+ ? bridgeState.totalCount
227
+ : totalCountFallback;
228
+
229
+ return { viewportRows, totalCount };
230
+ }