@adventurelabs/scout-core 1.4.31 → 1.4.33

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.
@@ -20,6 +20,7 @@ export declare const useInfiniteEventsByHerd: (herdId: number, options: UseInfin
20
20
  export declare const useInfiniteEventsByDevice: (deviceId: number, options: UseInfiniteScrollOptions) => InfiniteScrollData<IEventAndTagsPrettyLocation>;
21
21
  export declare const useInfiniteArtifactsByHerd: (herdId: number, options: UseInfiniteScrollOptions) => InfiniteScrollData<IArtifactWithMediaUrl>;
22
22
  export declare const useInfiniteArtifactsByDevice: (deviceId: number, options: UseInfiniteScrollOptions) => InfiniteScrollData<IArtifactWithMediaUrl>;
23
+ /** useInfiniteFeedByHerd: logic matches useInfiniteFeedByHerdDummy verbatim; only the fetch is via API (RTK Query) instead of supabase.rpc. */
23
24
  export declare const useInfiniteFeedByHerd: (herdId: number, options: UseInfiniteScrollOptions) => InfiniteScrollData<IFeedItem>;
24
25
  export declare const useInfiniteFeedByDevice: (deviceId: number, options: UseInfiniteScrollOptions) => InfiniteScrollData<IFeedItem>;
25
26
  export declare const useIntersectionObserver: (callback: () => void, options?: IntersectionObserverInit) => import("react").Dispatch<import("react").SetStateAction<Element | null>>;
@@ -408,96 +408,143 @@ export const useInfiniteArtifactsByDevice = (deviceId, options) => {
408
408
  };
409
409
  };
410
410
  const feedCursorEq = (a, b) => {
411
- if (a === null && b === null)
411
+ if (a === b)
412
412
  return true;
413
- if (!a || !b)
413
+ if (a == null || b == null)
414
414
  return false;
415
- return a.timestamp === b.timestamp && a.id === b.id && a.feed_type === b.feed_type;
415
+ return (a.timestamp === b.timestamp &&
416
+ a.id === b.id &&
417
+ a.feed_type === b.feed_type);
416
418
  };
419
+ /** useInfiniteFeedByHerd: logic matches useInfiniteFeedByHerdDummy verbatim; only the fetch is via API (RTK Query) instead of supabase.rpc. */
417
420
  export const useInfiniteFeedByHerd = (herdId, options) => {
421
+ const limit = options.limit ?? 20;
422
+ const enabled = !!(options.enabled && herdId);
418
423
  const [pages, setPages] = useState([]);
419
424
  const [currentCursor, setCurrentCursor] = useState(null);
420
- // Match dummy: use state so hasMore/nextCursor trigger re-renders and loadMore sees latest (ref was not updating UI)
421
- const [lastResult, setLastResult] = useState(null);
425
+ const [currentResult, setCurrentResult] = useState(null);
422
426
  const prevHerdIdRef = useRef(undefined);
423
427
  const lastAddedCursorRef = useRef(undefined);
424
428
  const pagesLengthRef = useRef(0);
429
+ /** When true, pass null to the query so we don't request (newHerdId, oldCursor) before state commits. */
430
+ const forceNullCursorRef = useRef(false);
431
+ const cursorForQuery = forceNullCursorRef.current ? null : currentCursor;
425
432
  const currentQuery = useGetFeedInfiniteByHerdQuery({
426
433
  herdId,
427
- limit: options.limit || 20,
428
- cursor: currentCursor,
434
+ limit,
435
+ cursor: cursorForQuery,
429
436
  supabase: options.supabase,
430
- }, { skip: !options.enabled || !herdId });
437
+ }, { skip: !enabled });
438
+ const isLoading = currentQuery.isLoading;
431
439
  useEffect(() => {
432
440
  pagesLengthRef.current = pages.length;
433
441
  }, [pages.length]);
434
- // Clear all state whenever herd id changes (including to/from undefined)
442
+ // Reset when herd changes (match dummy: prev !== herdId && enabled && herdId)
435
443
  useEffect(() => {
436
444
  if (prevHerdIdRef.current !== undefined &&
437
- prevHerdIdRef.current !== herdId) {
445
+ prevHerdIdRef.current !== herdId &&
446
+ enabled &&
447
+ herdId) {
448
+ forceNullCursorRef.current = true;
438
449
  setPages([]);
439
450
  setCurrentCursor(null);
451
+ setCurrentResult(null);
440
452
  lastAddedCursorRef.current = undefined;
441
- setLastResult(null);
442
453
  }
443
454
  prevHerdIdRef.current = herdId;
444
- }, [herdId]);
445
- // When we request a new page (cursor changed), clear ref so we merge the new response
455
+ }, [herdId, enabled]);
456
+ // When cursor changes, clear ref so we merge the new response
446
457
  useEffect(() => {
447
458
  lastAddedCursorRef.current = undefined;
448
459
  }, [currentCursor]);
460
+ // Merge when we have data (mirror dummy's .then() logic; fetch is done by RTK Query)
449
461
  useEffect(() => {
450
462
  if (!currentQuery.data || currentQuery.isLoading)
451
463
  return;
452
- // Match dummy: only skip merge when we already have pages and this cursor was already added as a full page
453
- if (pagesLengthRef.current > 0 &&
454
- feedCursorEq(lastAddedCursorRef.current ?? null, currentCursor))
455
- return;
456
- const items = Array.isArray(currentQuery.data?.items)
464
+ const cursor = cursorForQuery;
465
+ const items = Array.isArray(currentQuery.data.items)
457
466
  ? currentQuery.data.items
458
467
  : [];
459
- const limit = options.limit || 20;
460
- const nextResult = {
461
- hasMore: currentQuery.data.hasMore ?? false,
462
- nextCursor: currentQuery.data.nextCursor ?? null,
463
- };
464
- setLastResult(nextResult);
468
+ const nextCursor = currentQuery.data.nextCursor ?? null;
469
+ // Derive hasMore from items we received (like dummy), so we don't get stuck when API
470
+ // returns hasMore: false e.g. due to RPC/PostgREST returning fewer rows than limit.
471
+ const hasMore = (items.length >= limit || (items.length > 0 && items.length < limit)) &&
472
+ nextCursor != null;
473
+ // After herd switch we force null cursor; once we've merged that first page, allow normal cursor again
474
+ if (cursor === null) {
475
+ forceNullCursorRef.current = false;
476
+ }
477
+ // Only update currentResult for successful response (match dummy)
478
+ if (items.length === 0 && cursor === null) {
479
+ // Leave currentResult unchanged on spurious empty first page
480
+ }
481
+ else {
482
+ setCurrentResult({
483
+ hasMore,
484
+ nextCursor,
485
+ });
486
+ }
487
+ if (items.length === 0)
488
+ return;
489
+ // Skip merge exactly like dummy: full page already added for this cursor and we have pages
490
+ if (items.length >= limit &&
491
+ feedCursorEq(lastAddedCursorRef.current ?? null, cursor) &&
492
+ pagesLengthRef.current > 0) {
493
+ return;
494
+ }
465
495
  setPages((prev) => {
466
- const existingPage = prev.find((p) => feedCursorEq(p.cursor, currentCursor));
467
- if (!existingPage) {
468
- if (items.length >= limit) {
469
- lastAddedCursorRef.current = currentCursor;
470
- }
471
- return [
472
- ...prev,
473
- { cursor: currentCursor, data: items },
474
- ];
496
+ const existingPage = prev.find((p) => feedCursorEq(p.cursor, cursor));
497
+ const next = !existingPage
498
+ ? [...prev, { cursor, data: items }]
499
+ : items.length > existingPage.data.length
500
+ ? prev.map((p) => feedCursorEq(p.cursor, cursor) ? { cursor, data: items } : p)
501
+ : prev;
502
+ if (!existingPage && items.length >= limit) {
503
+ lastAddedCursorRef.current = cursor;
475
504
  }
476
- return prev;
505
+ if (existingPage &&
506
+ items.length > existingPage.data.length &&
507
+ items.length >= limit) {
508
+ lastAddedCursorRef.current = cursor;
509
+ }
510
+ return next;
477
511
  });
478
- // Request next page when this response has more (avoids relying on consumer's
479
- // IntersectionObserver when sentinel is already in view and callback doesn't re-fire).
480
- if (nextResult.hasMore && nextResult.nextCursor != null) {
481
- setCurrentCursor(nextResult.nextCursor);
482
- }
483
- }, [currentQuery.data, currentQuery.isLoading, currentCursor, pages.length, options.limit]);
512
+ }, [
513
+ currentQuery.data,
514
+ currentQuery.isLoading,
515
+ cursorForQuery,
516
+ pages.length,
517
+ limit,
518
+ ]);
484
519
  const loadMore = useCallback(() => {
485
- if (lastResult?.hasMore &&
486
- lastResult.nextCursor != null &&
487
- !currentQuery.isLoading) {
488
- setCurrentCursor(lastResult.nextCursor);
520
+ if (currentResult?.hasMore &&
521
+ currentResult.nextCursor != null &&
522
+ !isLoading) {
523
+ setCurrentCursor(currentResult.nextCursor);
489
524
  }
490
- }, [lastResult, currentQuery.isLoading]);
525
+ }, [currentResult, isLoading]);
491
526
  const refetch = useCallback(() => {
527
+ forceNullCursorRef.current = true;
492
528
  setPages([]);
493
529
  setCurrentCursor(null);
530
+ setCurrentResult(null);
494
531
  lastAddedCursorRef.current = undefined;
495
- setLastResult(null);
496
532
  currentQuery.refetch();
497
533
  }, [currentQuery]);
498
534
  const allItems = useMemo(() => {
535
+ const sorted = [...pages].sort((a, b) => {
536
+ if (feedCursorEq(a.cursor, b.cursor))
537
+ return 0;
538
+ if (a.cursor === null)
539
+ return -1;
540
+ if (b.cursor === null)
541
+ return 1;
542
+ const ta = a.cursor.timestamp ?? '';
543
+ const tb = b.cursor.timestamp ?? '';
544
+ return tb.localeCompare(ta);
545
+ });
499
546
  const seen = new Set();
500
- return pages.flatMap((p) => p.data).filter((item) => {
547
+ return sorted.flatMap((p) => p.data).filter((item) => {
501
548
  const key = `${item.sort_ts ?? ''}_${item.sort_id ?? ''}_${item.feed_type ?? ''}`;
502
549
  if (seen.has(key))
503
550
  return false;
@@ -507,10 +554,10 @@ export const useInfiniteFeedByHerd = (herdId, options) => {
507
554
  }, [pages]);
508
555
  return {
509
556
  items: allItems,
510
- isLoading: currentQuery.isLoading && pages.length === 0,
511
- isLoadingMore: currentQuery.isLoading && pages.length > 0,
512
- hasMore: (lastResult?.hasMore ??
513
- (currentCursor !== null && pages.length > 0)) ??
557
+ isLoading: isLoading && pages.length === 0,
558
+ isLoadingMore: isLoading && pages.length > 0,
559
+ hasMore: currentResult?.hasMore ??
560
+ (currentCursor !== null && pages.length > 0) ??
514
561
  false,
515
562
  loadMore,
516
563
  refetch,
@@ -524,10 +571,12 @@ export const useInfiniteFeedByDevice = (deviceId, options) => {
524
571
  const prevDeviceIdRef = useRef(undefined);
525
572
  const lastAddedCursorRef = useRef(undefined);
526
573
  const pagesLengthRef = useRef(0);
574
+ const forceNullCursorRef = useRef(false);
575
+ const cursorForQuery = forceNullCursorRef.current ? null : currentCursor;
527
576
  const currentQuery = useGetFeedInfiniteByDeviceQuery({
528
577
  deviceId,
529
578
  limit: options.limit || 20,
530
- cursor: currentCursor,
579
+ cursor: cursorForQuery,
531
580
  supabase: options.supabase,
532
581
  }, { skip: !options.enabled || !deviceId });
533
582
  useEffect(() => {
@@ -537,6 +586,7 @@ export const useInfiniteFeedByDevice = (deviceId, options) => {
537
586
  useEffect(() => {
538
587
  if (prevDeviceIdRef.current !== undefined &&
539
588
  prevDeviceIdRef.current !== deviceId) {
589
+ forceNullCursorRef.current = true;
540
590
  setPages([]);
541
591
  setCurrentCursor(null);
542
592
  lastAddedCursorRef.current = undefined;
@@ -551,35 +601,39 @@ export const useInfiniteFeedByDevice = (deviceId, options) => {
551
601
  useEffect(() => {
552
602
  if (!currentQuery.data || currentQuery.isLoading)
553
603
  return;
604
+ const cursor = cursorForQuery;
605
+ if (cursor === null) {
606
+ forceNullCursorRef.current = false;
607
+ }
554
608
  if (pagesLengthRef.current > 0 &&
555
- feedCursorEq(lastAddedCursorRef.current ?? null, currentCursor))
609
+ feedCursorEq(lastAddedCursorRef.current ?? null, cursor))
556
610
  return;
557
- const nextResult = {
558
- hasMore: currentQuery.data?.hasMore ?? false,
559
- nextCursor: currentQuery.data?.nextCursor ?? null,
560
- };
561
- setLastResult(nextResult);
611
+ const items = Array.isArray(currentQuery.data?.items)
612
+ ? currentQuery.data.items
613
+ : [];
614
+ const limitForPage = options.limit || 20;
615
+ const nextCursor = currentQuery.data?.nextCursor ?? null;
616
+ const hasMore = (items.length >= limitForPage ||
617
+ (items.length > 0 && items.length < limitForPage)) &&
618
+ nextCursor != null;
619
+ setLastResult({ hasMore, nextCursor });
562
620
  setPages((prev) => {
563
- const existingPage = prev.find((p) => feedCursorEq(p.cursor, currentCursor));
621
+ const existingPage = prev.find((p) => feedCursorEq(p.cursor, cursor));
564
622
  if (!existingPage) {
565
- const items = Array.isArray(currentQuery.data?.items)
566
- ? currentQuery.data.items
567
- : [];
568
- const limit = options.limit || 20;
569
- if (items.length >= limit) {
570
- lastAddedCursorRef.current = currentCursor;
623
+ if (items.length >= limitForPage) {
624
+ lastAddedCursorRef.current = cursor;
571
625
  }
572
626
  return [
573
627
  ...prev,
574
- { cursor: currentCursor, data: items },
628
+ { cursor, data: items },
575
629
  ];
576
630
  }
577
631
  return prev;
578
632
  });
579
- if (nextResult.hasMore && nextResult.nextCursor != null) {
580
- setCurrentCursor(nextResult.nextCursor);
633
+ if (hasMore && nextCursor != null) {
634
+ setCurrentCursor(nextCursor);
581
635
  }
582
- }, [currentQuery.data, currentQuery.isLoading, currentCursor, pages.length, options.limit]);
636
+ }, [currentQuery.data, currentQuery.isLoading, cursorForQuery, pages.length, options.limit]);
583
637
  const loadMore = useCallback(() => {
584
638
  if (lastResult?.hasMore &&
585
639
  lastResult.nextCursor != null &&
@@ -588,6 +642,7 @@ export const useInfiniteFeedByDevice = (deviceId, options) => {
588
642
  }
589
643
  }, [lastResult, currentQuery.isLoading]);
590
644
  const refetch = useCallback(() => {
645
+ forceNullCursorRef.current = true;
591
646
  setPages([]);
592
647
  setCurrentCursor(null);
593
648
  lastAddedCursorRef.current = undefined;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@adventurelabs/scout-core",
3
- "version": "1.4.31",
3
+ "version": "1.4.33",
4
4
  "description": "Core utilities and helpers for Adventure Labs Scout applications",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",