@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,807 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useLayoutEffect,
5
+ useMemo,
6
+ useRef,
7
+ useState,
8
+ useSyncExternalStore,
9
+ } from "react";
10
+ import { CacheManager } from "../cache-manager";
11
+ import { connectPartialSync } from "../connect-partial-sync";
12
+ import {
13
+ PartialSyncClientBridge,
14
+ type PartialSyncRangePatchAppliedEvent,
15
+ type PartialSyncState,
16
+ } from "../partial-sync-client-bridge";
17
+ import type { RangeFingerprint, SyncClientMessage } from "../sync-protocol";
18
+ import { DEFAULT_PAGE_LIMIT, DEFAULT_SEEK_COOLDOWN_MS } from "./constants";
19
+ import { partialSyncRowKey } from "../partial-sync-row-key";
20
+ import {
21
+ assertSyncUtils,
22
+ computeFingerprintForIndexWindow,
23
+ defaultPartialSyncVersionMs,
24
+ getPartialSyncRowByMapId,
25
+ tryIdsForIndexWindow,
26
+ } from "./partial-sync-utils";
27
+ import type {
28
+ PartialSyncItem,
29
+ PartialSyncRowSlotView,
30
+ UsePartialSyncWindowOptions,
31
+ UsePartialSyncWindowResult,
32
+ ViewportInfo,
33
+ } from "./types";
34
+
35
+ export {
36
+ DEFAULT_PAGE_LIMIT,
37
+ DEFAULT_SEEK_COOLDOWN_MS,
38
+ DEFAULT_SEEK_ROW_GAP,
39
+ DEFAULT_VIEWPORT_RANGE_MAX_WAIT_MS,
40
+ DEFAULT_VIEWPORT_RANGE_QUIET_MS,
41
+ } from "./constants";
42
+
43
+ export function usePartialSyncWindow<
44
+ TItem extends PartialSyncItem,
45
+ TSortColumn extends keyof TItem & string,
46
+ >({
47
+ collection,
48
+ sort,
49
+ getSortValue,
50
+ wsUrl,
51
+ wsTransport = "json",
52
+ serializeJson = JSON.stringify,
53
+ deserializeJson = JSON.parse,
54
+ getVersionMs = defaultPartialSyncVersionMs,
55
+ getSortPositions,
56
+ pageLimit = DEFAULT_PAGE_LIMIT,
57
+ seekCooldownMs = DEFAULT_SEEK_COOLDOWN_MS,
58
+ partialWindowResetKey,
59
+ mutationBridge,
60
+ mergeTransportSend,
61
+ collectionId,
62
+ cacheDisplayMode = "immediate",
63
+ }: UsePartialSyncWindowOptions<
64
+ TItem,
65
+ TSortColumn
66
+ >): UsePartialSyncWindowResult<TItem> {
67
+ const [windowStartIndex, setWindowStartIndex] = useState(0);
68
+ const [totalCount, setTotalCount] = useState(0);
69
+ const [nextCursor, setNextCursor] = useState<unknown | null>(null);
70
+ const [hasMore, setHasMore] = useState(true);
71
+ /** True only while `requestRangeQuery` is in flight (server). Not set for cache-only window moves. */
72
+ const [rangeRequestInFlight, setRangeRequestInFlight] = useState(false);
73
+ const [pendingServerRange, setPendingServerRange] = useState<{
74
+ start: number;
75
+ endExclusive: number;
76
+ } | null>(null);
77
+ const [bridgeState, setBridgeState] = useState<PartialSyncState>({
78
+ status: "offline",
79
+ });
80
+ const [viewportInfo, setViewportInfo] = useState<ViewportInfo>({
81
+ firstVisibleIndex: 0,
82
+ lastVisibleIndex: 0,
83
+ });
84
+ const viewportInfoRef = useRef(viewportInfo);
85
+ viewportInfoRef.current = viewportInfo;
86
+ const [lastSeekMeta, setLastSeekMeta] = useState<{
87
+ offset: number;
88
+ reason: "scroll" | "scrollSettled";
89
+ } | null>(null);
90
+ const [collectionVersion, setCollectionVersion] = useState(0);
91
+ const [indexMapVersion, setIndexMapVersion] = useState(0);
92
+
93
+ const globalIndexMapRef = useRef(new Map<number, string | number>());
94
+ const denseRowsRef = useRef<TItem[]>([]);
95
+ const windowStartRef = useRef(windowStartIndex);
96
+ windowStartRef.current = windowStartIndex;
97
+ const sortRef = useRef(sort);
98
+ sortRef.current = sort;
99
+ const totalCountRef = useRef(totalCount);
100
+ totalCountRef.current = totalCount;
101
+ const fetchGenRef = useRef(0);
102
+ const seekCooldownUntilRef = useRef(0);
103
+ const seekToViewportRef = useRef<
104
+ (
105
+ firstVisibleIndex: number,
106
+ opts?: {
107
+ scrollSettled?: boolean;
108
+ lastVisibleIndex?: number;
109
+ force?: boolean;
110
+ },
111
+ ) => void
112
+ >(() => {});
113
+ const invalidateSeekTimerRef = useRef<ReturnType<typeof setTimeout> | null>(
114
+ null,
115
+ );
116
+
117
+ const getSortValueRef = useRef(getSortValue);
118
+ getSortValueRef.current = getSortValue;
119
+ const getSortPositionsRef = useRef(getSortPositions);
120
+ getSortPositionsRef.current = getSortPositions;
121
+ const getVersionMsRef = useRef(getVersionMs);
122
+ getVersionMsRef.current = getVersionMs;
123
+
124
+ const serializeJsonRef = useRef(serializeJson);
125
+ serializeJsonRef.current = serializeJson;
126
+ const deserializeJsonRef = useRef(deserializeJson);
127
+ deserializeJsonRef.current = deserializeJson;
128
+
129
+ const bumpIndexMap = useCallback(() => {
130
+ setIndexMapVersion((v) => v + 1);
131
+ }, []);
132
+
133
+ const syncUtils = useMemo(
134
+ () => assertSyncUtils<TItem>(collection.utils),
135
+ [collection],
136
+ );
137
+
138
+ const syncUtilsRef = useRef(syncUtils);
139
+ syncUtilsRef.current = syncUtils;
140
+
141
+ const collectionRef = useRef(collection);
142
+ collectionRef.current = collection;
143
+
144
+ const cacheManager = useMemo(
145
+ () =>
146
+ new CacheManager<TItem>({
147
+ getStorageEstimate: async () => {
148
+ if (
149
+ typeof navigator === "undefined" ||
150
+ !navigator.storage?.estimate
151
+ ) {
152
+ const usageBytes = 0;
153
+ const quotaBytes = 50 * 1024 * 1024;
154
+ return {
155
+ usageBytes,
156
+ quotaBytes,
157
+ utilizationRatio: usageBytes / quotaBytes,
158
+ };
159
+ }
160
+ const estimate = await navigator.storage.estimate();
161
+ const usageBytes = estimate.usage ?? 0;
162
+ const quotaBytes = estimate.quota ?? 1;
163
+ return {
164
+ usageBytes,
165
+ quotaBytes,
166
+ utilizationRatio: usageBytes / quotaBytes,
167
+ };
168
+ },
169
+ deleteRows: async (keys) => {
170
+ const keySet = new Set<string | number>();
171
+ for (const k of keys) {
172
+ keySet.add(k);
173
+ keySet.add(String(k));
174
+ }
175
+ for (const [idx, id] of globalIndexMapRef.current) {
176
+ if (
177
+ keySet.has(id) ||
178
+ keySet.has(String(id)) ||
179
+ (typeof id === "string" &&
180
+ /^-?\d+$/.test(id) &&
181
+ keySet.has(Number(id)))
182
+ ) {
183
+ globalIndexMapRef.current.delete(idx);
184
+ }
185
+ }
186
+ bumpIndexMap();
187
+ await syncUtilsRef.current.receiveSync(
188
+ keys.map((key) => ({ type: "delete", key }) as const),
189
+ );
190
+ },
191
+ }),
192
+ [bumpIndexMap],
193
+ );
194
+
195
+ const cacheManagerRef = useRef(cacheManager);
196
+ cacheManagerRef.current = cacheManager;
197
+
198
+ // Bridge identity must stay stable: `connectPartialSync` runs in layout effect and
199
+ // calls `setConnecting` / `setConnected`, which updates React state. If `bridge`
200
+ // were recreated whenever `collection` (hence syncUtils/cacheManager) changed,
201
+ // that effect would re-run every render → infinite updates.
202
+ const partialClientId = mutationBridge?.clientId;
203
+
204
+ const bridge = useMemo(
205
+ () =>
206
+ new PartialSyncClientBridge<TItem>({
207
+ ...(partialClientId !== undefined ? { clientId: partialClientId } : {}),
208
+ ...(collectionId !== undefined ? { collectionId } : {}),
209
+ collection: {
210
+ get: (key) => getPartialSyncRowByMapId(collectionRef.current, key),
211
+ utils: {
212
+ receiveSync: (messages) =>
213
+ syncUtilsRef.current.receiveSync(messages),
214
+ },
215
+ },
216
+ send: () => {},
217
+ onStateChange: (state) => setBridgeState(state),
218
+ beforeApplyRows: async (incomingRows) => {
219
+ const sortNow = sortRef.current;
220
+ const rowsNow = denseRowsRef.current;
221
+ const sortPos = getSortPositionsRef.current;
222
+ const gsv = getSortValueRef.current;
223
+ const cm = cacheManagerRef.current;
224
+ cm.recordFetchedRows(incomingRows, (row) =>
225
+ sortPos !== undefined
226
+ ? sortPos(row)
227
+ : {
228
+ [sortNow.column]: gsv(row, sortNow.column),
229
+ },
230
+ );
231
+ const firstRow = rowsNow[0] ?? incomingRows[0];
232
+ const lastRow =
233
+ rowsNow[rowsNow.length - 1] ??
234
+ incomingRows[incomingRows.length - 1];
235
+ const result = await cm.evictIfNeeded({
236
+ sortColumn: sortNow.column as string,
237
+ sortDirection: sortNow.direction,
238
+ fromValue:
239
+ firstRow !== undefined ? gsv(firstRow, sortNow.column) : "",
240
+ toValue: lastRow !== undefined ? gsv(lastRow, sortNow.column) : "",
241
+ });
242
+ setBridgeState((previous) => {
243
+ if (
244
+ previous.status === "partial" ||
245
+ previous.status === "realtime"
246
+ ) {
247
+ return {
248
+ ...previous,
249
+ cacheUtilization: result.estimate.utilizationRatio,
250
+ };
251
+ }
252
+ return previous;
253
+ });
254
+ },
255
+ onViewTransition: (e) => {
256
+ if (e.type === "exitView" && e.change.type === "update") {
257
+ const key = partialSyncRowKey(e.change.value.id);
258
+ let removed = false;
259
+ for (const [idx, mappedId] of [
260
+ ...globalIndexMapRef.current.entries(),
261
+ ]) {
262
+ if (mappedId === key) {
263
+ globalIndexMapRef.current.delete(idx);
264
+ removed = true;
265
+ }
266
+ }
267
+ if (removed) {
268
+ bumpIndexMap();
269
+ }
270
+ }
271
+ },
272
+ onRangePatchApplied: ({
273
+ change,
274
+ viewTransition,
275
+ }: PartialSyncRangePatchAppliedEvent<TItem>) => {
276
+ if (change.type !== "update" || viewTransition !== undefined) {
277
+ return;
278
+ }
279
+ if (change.previousValue === undefined) return;
280
+ const col = sortRef.current.column;
281
+ const gsv = getSortValueRef.current;
282
+ if (
283
+ gsv(change.previousValue as TItem, col) === gsv(change.value, col)
284
+ ) {
285
+ return;
286
+ }
287
+ const rowKey = partialSyncRowKey(change.value.id);
288
+ const inDense = denseRowsRef.current.some(
289
+ (r) => partialSyncRowKey(r.id) === rowKey,
290
+ );
291
+ if (!inDense) return;
292
+ if (invalidateSeekTimerRef.current !== null) {
293
+ clearTimeout(invalidateSeekTimerRef.current);
294
+ }
295
+ invalidateSeekTimerRef.current = setTimeout(() => {
296
+ invalidateSeekTimerRef.current = null;
297
+ seekToViewportRef.current(
298
+ viewportInfoRef.current.firstVisibleIndex,
299
+ {
300
+ force: true,
301
+ },
302
+ );
303
+ }, 80);
304
+ },
305
+ }),
306
+ [partialClientId, collectionId, bumpIndexMap],
307
+ );
308
+
309
+ // Do not list serializeJson / deserializeJson as effect deps: callers often pass
310
+ // inline lambdas (new reference every render). That would re-run this layout effect
311
+ // every time → cleanup calls setConnected(false) and the next run calls setConnecting,
312
+ // each firing onStateChange → maximum update depth exceeded.
313
+ const mutationBridgeRef = useRef(mutationBridge);
314
+ mutationBridgeRef.current = mutationBridge;
315
+
316
+ const mergeTransportSendRef = useRef(mergeTransportSend);
317
+ mergeTransportSendRef.current = mergeTransportSend;
318
+
319
+ useLayoutEffect(() => {
320
+ const disconnect = connectPartialSync(bridge, {
321
+ url: wsUrl,
322
+ transport: wsTransport,
323
+ setTransportSend: (send) => {
324
+ bridge.setSend((message: SyncClientMessage) => send(message));
325
+ mergeTransportSendRef.current?.(send);
326
+ },
327
+ serializeJson: (value: unknown) => serializeJsonRef.current(value),
328
+ deserializeJson: (raw: string) => deserializeJsonRef.current(raw),
329
+ mutationBridge: mutationBridgeRef.current,
330
+ });
331
+ return () => {
332
+ disconnect();
333
+ };
334
+ }, [bridge, wsTransport, wsUrl]);
335
+
336
+ const confirmedKeysRevision = useSyncExternalStore(
337
+ (onStoreChange) => bridge.subscribeConfirmedKeysRevision(onStoreChange),
338
+ () => bridge.serverConfirmedKeysRevision,
339
+ () => 0,
340
+ );
341
+
342
+ const indexRows = useMemo(() => {
343
+ void collectionVersion;
344
+ void indexMapVersion;
345
+ void confirmedKeysRevision;
346
+ const start = windowStartIndex;
347
+ const out: TItem[] = [];
348
+ for (let i = 0; ; i += 1) {
349
+ const id = globalIndexMapRef.current.get(start + i);
350
+ if (id === undefined) break;
351
+ const row = getPartialSyncRowByMapId(collection, id);
352
+ if (row === undefined) continue;
353
+ out.push(row);
354
+ }
355
+ if (cacheDisplayMode === "confirmed") {
356
+ return out.filter((row) =>
357
+ bridge.serverConfirmedKeys.has(partialSyncRowKey(row.id)),
358
+ );
359
+ }
360
+ return out;
361
+ }, [
362
+ bridge,
363
+ cacheDisplayMode,
364
+ collection,
365
+ collectionVersion,
366
+ confirmedKeysRevision,
367
+ indexMapVersion,
368
+ windowStartIndex,
369
+ ]);
370
+
371
+ const rows = indexRows;
372
+
373
+ useLayoutEffect(() => {
374
+ denseRowsRef.current = rows;
375
+ }, [rows]);
376
+
377
+ const getRowSlot = useCallback(
378
+ (globalIndex: number): PartialSyncRowSlotView<TItem> => {
379
+ void collectionVersion;
380
+ void indexMapVersion;
381
+ const id = globalIndexMapRef.current.get(globalIndex);
382
+ const row =
383
+ id !== undefined ? getPartialSyncRowByMapId(collection, id) : undefined;
384
+ if (row !== undefined) {
385
+ const ws = windowStartIndex;
386
+ const denseEnd = ws + rows.length;
387
+ const inDense = globalIndex >= ws && globalIndex < denseEnd;
388
+ return {
389
+ row,
390
+ slot: inDense ? "ready" : "ready_global",
391
+ };
392
+ }
393
+ if (id !== undefined) {
394
+ return { row: undefined, slot: "stale_map" };
395
+ }
396
+ if (
397
+ rangeRequestInFlight &&
398
+ pendingServerRange !== null &&
399
+ globalIndex >= pendingServerRange.start &&
400
+ globalIndex < pendingServerRange.endExclusive
401
+ ) {
402
+ return { row: undefined, slot: "server" };
403
+ }
404
+ return { row: undefined, slot: "none" };
405
+ },
406
+ [
407
+ collection,
408
+ collectionVersion,
409
+ indexMapVersion,
410
+ windowStartIndex,
411
+ rows.length,
412
+ rangeRequestInFlight,
413
+ pendingServerRange,
414
+ ],
415
+ );
416
+
417
+ const recordIdsAtOffset = useCallback(
418
+ (offset: number, fetchedRows: TItem[]) => {
419
+ for (let i = 0; i < fetchedRows.length; i += 1) {
420
+ globalIndexMapRef.current.set(
421
+ offset + i,
422
+ partialSyncRowKey(fetchedRows[i].id),
423
+ );
424
+ }
425
+ bumpIndexMap();
426
+ },
427
+ [bumpIndexMap],
428
+ );
429
+
430
+ const fetchNext = useCallback(async () => {
431
+ if (rangeRequestInFlight || !hasMore) return;
432
+ const gen = fetchGenRef.current;
433
+ const fetchStart = windowStartRef.current + denseRowsRef.current.length;
434
+ setPendingServerRange({
435
+ start: fetchStart,
436
+ endExclusive: fetchStart + pageLimit,
437
+ });
438
+ setRangeRequestInFlight(true);
439
+ try {
440
+ const result = await bridge.requestRangeQuery({
441
+ kind: "index",
442
+ mode: "cursor",
443
+ sort: sortRef.current as { column: string; direction: "asc" | "desc" },
444
+ limit: pageLimit,
445
+ afterCursor: nextCursor,
446
+ });
447
+ if (gen !== fetchGenRef.current) return;
448
+
449
+ if (result.invalidateWindow && result.rows.length === 0) {
450
+ const again = await bridge.requestRangeQuery({
451
+ kind: "index",
452
+ mode: "cursor",
453
+ sort: sortRef.current as {
454
+ column: string;
455
+ direction: "asc" | "desc";
456
+ },
457
+ limit: pageLimit,
458
+ afterCursor: nextCursor,
459
+ });
460
+ if (gen !== fetchGenRef.current) return;
461
+ recordIdsAtOffset(
462
+ windowStartRef.current + denseRowsRef.current.length,
463
+ again.rows,
464
+ );
465
+ setTotalCount(again.totalCount);
466
+ setNextCursor(again.lastCursor);
467
+ setHasMore(again.rows.length === pageLimit);
468
+ return;
469
+ }
470
+
471
+ if (result.upToDate) {
472
+ setTotalCount(result.totalCount);
473
+ return;
474
+ }
475
+
476
+ recordIdsAtOffset(
477
+ windowStartRef.current + denseRowsRef.current.length,
478
+ result.rows,
479
+ );
480
+ setTotalCount(result.totalCount);
481
+ setNextCursor(result.lastCursor);
482
+ setHasMore(result.rows.length === pageLimit);
483
+ } catch (error: unknown) {
484
+ if (
485
+ error !== null &&
486
+ typeof error === "object" &&
487
+ "name" in error &&
488
+ error.name === "AbortError"
489
+ ) {
490
+ return;
491
+ }
492
+ throw error;
493
+ } finally {
494
+ if (gen === fetchGenRef.current) {
495
+ setPendingServerRange(null);
496
+ setRangeRequestInFlight(false);
497
+ }
498
+ }
499
+ }, [
500
+ bridge,
501
+ hasMore,
502
+ rangeRequestInFlight,
503
+ nextCursor,
504
+ pageLimit,
505
+ recordIdsAtOffset,
506
+ ]);
507
+
508
+ const seekToViewport = useCallback(
509
+ (
510
+ firstVisibleIndex: number,
511
+ options?: {
512
+ scrollSettled?: boolean;
513
+ lastVisibleIndex?: number;
514
+ force?: boolean;
515
+ },
516
+ ) => {
517
+ const offset = Math.max(0, firstVisibleIndex);
518
+ const loadedEndExclusive =
519
+ windowStartRef.current + denseRowsRef.current.length;
520
+ // For scrollSettled, `lastVisibleIndex` comes from scroll geometry (viewport height), not
521
+ // TanStack overscan — safe to require the whole visible span to fit in the dense window.
522
+ // Without last, keep first-only (e.g. non-settled callers).
523
+ const lastForDense =
524
+ options?.scrollSettled === true &&
525
+ typeof options.lastVisibleIndex === "number"
526
+ ? options.lastVisibleIndex
527
+ : firstVisibleIndex;
528
+ const inDenseWindow =
529
+ denseRowsRef.current.length > 0 &&
530
+ firstVisibleIndex >= windowStartRef.current &&
531
+ lastForDense < loadedEndExclusive;
532
+
533
+ if (options?.scrollSettled === true && inDenseWindow && !options?.force) {
534
+ return;
535
+ }
536
+
537
+ const now = Date.now();
538
+ if (!options?.force && now < seekCooldownUntilRef.current) {
539
+ return;
540
+ }
541
+ if (!options?.scrollSettled) {
542
+ // Live scroll: virtualizer updates indices before `windowStartIndex` catches up (seek was
543
+ // scrollSettled-only). If the first visible row is outside [windowStart, loadedEnd), reconcile
544
+ // immediately (throttled) so we do not paint "not cached yet" until scrollend.
545
+ const ws = windowStartRef.current;
546
+ const f = firstVisibleIndex;
547
+ const firstInsideDense =
548
+ denseRowsRef.current.length > 0 && f >= ws && f < loadedEndExclusive;
549
+ if (firstInsideDense && !options?.force) return;
550
+ }
551
+ seekCooldownUntilRef.current = now + seekCooldownMs;
552
+
553
+ setLastSeekMeta({
554
+ offset,
555
+ reason: options?.scrollSettled === true ? "scrollSettled" : "scroll",
556
+ });
557
+ fetchGenRef.current += 1;
558
+ const gen = fetchGenRef.current;
559
+ bridge.abortRangeRequests();
560
+
561
+ const want = Math.min(
562
+ pageLimit,
563
+ Math.max(0, totalCountRef.current - offset),
564
+ );
565
+ if (totalCountRef.current > 0 && want === 0) {
566
+ setWindowStartIndex(offset);
567
+ windowStartRef.current = offset;
568
+ setNextCursor(null);
569
+ setHasMore(false);
570
+ setPendingServerRange(null);
571
+ setRangeRequestInFlight(false);
572
+ return;
573
+ }
574
+
575
+ const ids = tryIdsForIndexWindow(
576
+ globalIndexMapRef.current,
577
+ offset,
578
+ want,
579
+ totalCountRef.current,
580
+ );
581
+ if (ids !== null) {
582
+ setWindowStartIndex(offset);
583
+ const lastId = ids[ids.length - 1];
584
+ const gsv = getSortValueRef.current;
585
+ const lastRow =
586
+ lastId !== undefined
587
+ ? getPartialSyncRowByMapId(collection, lastId)
588
+ : undefined;
589
+ setNextCursor(
590
+ lastRow !== undefined ? gsv(lastRow, sortRef.current.column) : null,
591
+ );
592
+ setHasMore(offset + ids.length < totalCountRef.current);
593
+ setPendingServerRange(null);
594
+ bumpIndexMap();
595
+ windowStartRef.current = offset;
596
+ return;
597
+ }
598
+
599
+ let fingerprint: RangeFingerprint | undefined;
600
+ if (totalCountRef.current > 0 && want > 0) {
601
+ const fp = computeFingerprintForIndexWindow(
602
+ collection,
603
+ globalIndexMapRef.current,
604
+ offset,
605
+ want,
606
+ (row) => getVersionMsRef.current(row),
607
+ );
608
+ if (fp !== undefined) {
609
+ fingerprint = fp;
610
+ }
611
+ }
612
+
613
+ setWindowStartIndex(offset);
614
+ windowStartRef.current = offset;
615
+ setNextCursor(null);
616
+ setHasMore(true);
617
+ setPendingServerRange({ start: offset, endExclusive: offset + want });
618
+ setRangeRequestInFlight(true);
619
+ void (async () => {
620
+ try {
621
+ let result = await bridge.requestRangeQuery(
622
+ {
623
+ kind: "index",
624
+ mode: "offset",
625
+ sort: sortRef.current as {
626
+ column: string;
627
+ direction: "asc" | "desc";
628
+ },
629
+ limit: pageLimit,
630
+ offset,
631
+ },
632
+ fingerprint,
633
+ );
634
+ if (gen !== fetchGenRef.current) return;
635
+
636
+ if (result.upToDate) {
637
+ setTotalCount(result.totalCount);
638
+ setHasMore(offset + pageLimit < result.totalCount);
639
+ const gsv = getSortValueRef.current;
640
+ const lastId =
641
+ globalIndexMapRef.current.get(offset + want - 1) ??
642
+ globalIndexMapRef.current.get(offset + pageLimit - 1);
643
+ const lastRow =
644
+ lastId !== undefined
645
+ ? getPartialSyncRowByMapId(collection, lastId)
646
+ : undefined;
647
+ setNextCursor(
648
+ lastRow !== undefined
649
+ ? gsv(lastRow, sortRef.current.column)
650
+ : null,
651
+ );
652
+ return;
653
+ }
654
+
655
+ if (result.invalidateWindow && result.rows.length === 0) {
656
+ result = await bridge.requestRangeQuery({
657
+ kind: "index",
658
+ mode: "offset",
659
+ sort: sortRef.current as {
660
+ column: string;
661
+ direction: "asc" | "desc";
662
+ },
663
+ limit: pageLimit,
664
+ offset,
665
+ });
666
+ if (gen !== fetchGenRef.current) return;
667
+ }
668
+
669
+ recordIdsAtOffset(offset, result.rows);
670
+ setTotalCount(result.totalCount);
671
+ setNextCursor(result.lastCursor);
672
+ setHasMore(result.rows.length === pageLimit);
673
+ } catch (error: unknown) {
674
+ if (
675
+ error !== null &&
676
+ typeof error === "object" &&
677
+ "name" in error &&
678
+ error.name === "AbortError"
679
+ ) {
680
+ return;
681
+ }
682
+ throw error;
683
+ } finally {
684
+ if (gen === fetchGenRef.current) {
685
+ setPendingServerRange(null);
686
+ setRangeRequestInFlight(false);
687
+ }
688
+ }
689
+ })();
690
+ },
691
+ [
692
+ bridge,
693
+ bumpIndexMap,
694
+ collection,
695
+ pageLimit,
696
+ recordIdsAtOffset,
697
+ seekCooldownMs,
698
+ ],
699
+ );
700
+
701
+ seekToViewportRef.current = seekToViewport;
702
+
703
+ useEffect(() => {
704
+ return () => {
705
+ if (invalidateSeekTimerRef.current !== null) {
706
+ clearTimeout(invalidateSeekTimerRef.current);
707
+ }
708
+ };
709
+ }, []);
710
+
711
+ useEffect(() => {
712
+ const sub = collection.subscribeChanges(() => {
713
+ // Remove individual stale map entries (row deleted from collection, but map still
714
+ // references it). This is lightweight: no wholesale clear, no force-seek, no abort.
715
+ // `indexRows` already skips stale entries so the UI stays populated.
716
+ let removedStaleMapEntry = false;
717
+ if (globalIndexMapRef.current.size > 0) {
718
+ for (const [idx, id] of globalIndexMapRef.current) {
719
+ if (getPartialSyncRowByMapId(collection, id) === undefined) {
720
+ globalIndexMapRef.current.delete(idx);
721
+ removedStaleMapEntry = true;
722
+ }
723
+ }
724
+ }
725
+ if (removedStaleMapEntry) {
726
+ bumpIndexMap();
727
+ }
728
+
729
+ setCollectionVersion((v) => v + 1);
730
+ const sortNow = sortRef.current;
731
+ const sortPos = getSortPositionsRef.current;
732
+ const gsv = getSortValueRef.current;
733
+ cacheManagerRef.current.resyncSortPositionsForTrackedRows(
734
+ (key) => getPartialSyncRowByMapId(collection, key),
735
+ (row) =>
736
+ sortPos !== undefined
737
+ ? sortPos(row)
738
+ : {
739
+ [sortNow.column]: gsv(row, sortNow.column),
740
+ },
741
+ );
742
+ });
743
+ return () => {
744
+ sub.unsubscribe();
745
+ };
746
+ }, [bumpIndexMap, collection]);
747
+
748
+ const seekAfterScrollSettled = useCallback(
749
+ (firstVisibleIndex: number, lastVisibleIndex?: number) => {
750
+ seekToViewport(firstVisibleIndex, {
751
+ scrollSettled: true,
752
+ lastVisibleIndex,
753
+ });
754
+ },
755
+ [seekToViewport],
756
+ );
757
+
758
+ // Re-run when sort or logical collection identity changes — not on TanStack `collection` ref churn.
759
+ // biome-ignore lint/correctness/useExhaustiveDependencies: intentional narrow deps; truncate uses syncUtilsRef
760
+ useEffect(() => {
761
+ fetchGenRef.current += 1;
762
+ bridge.abortRangeRequests();
763
+ bridge.clearTrackedRowIds();
764
+ setWindowStartIndex(0);
765
+ setTotalCount(0);
766
+ setNextCursor(null);
767
+ setHasMore(true);
768
+ setLastSeekMeta(null);
769
+ setRangeRequestInFlight(false);
770
+ setPendingServerRange(null);
771
+ globalIndexMapRef.current.clear();
772
+ bumpIndexMap();
773
+ void syncUtilsRef.current.truncate();
774
+ cacheManager.clear();
775
+ }, [
776
+ bridge,
777
+ bumpIndexMap,
778
+ cacheManager,
779
+ sort.column,
780
+ sort.direction,
781
+ partialWindowResetKey ?? "",
782
+ ]);
783
+
784
+ useEffect(() => {
785
+ if (rows.length === 0 && !rangeRequestInFlight && hasMore) {
786
+ void fetchNext();
787
+ }
788
+ }, [fetchNext, hasMore, rangeRequestInFlight, rows.length]);
789
+
790
+ return {
791
+ bridge,
792
+ cacheManager,
793
+ rows,
794
+ windowStartIndex,
795
+ totalCount,
796
+ rangeRequestInFlight,
797
+ hasMore,
798
+ fetchNext,
799
+ seekToViewport,
800
+ seekAfterScrollSettled,
801
+ bridgeState,
802
+ viewportInfo,
803
+ setViewportInfo,
804
+ lastSeekMeta,
805
+ getRowSlot,
806
+ };
807
+ }