@workflow/web-shared 5.0.0-beta.10 → 5.0.0-beta.11

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.
Files changed (55) hide show
  1. package/dist/components/index.d.ts +1 -0
  2. package/dist/components/index.d.ts.map +1 -1
  3. package/dist/components/index.js +2 -1
  4. package/dist/components/new-trace-viewer/components/event-list.d.ts.map +1 -1
  5. package/dist/components/new-trace-viewer/components/event-list.js +9 -4
  6. package/dist/components/new-trace-viewer/components/split-pane.d.ts +3 -2
  7. package/dist/components/new-trace-viewer/components/split-pane.d.ts.map +1 -1
  8. package/dist/components/new-trace-viewer/components/split-pane.js +10 -4
  9. package/dist/components/new-trace-viewer/components/timeline.d.ts +1 -1
  10. package/dist/components/new-trace-viewer/components/timeline.d.ts.map +1 -1
  11. package/dist/components/new-trace-viewer/components/timeline.js +6 -8
  12. package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.d.ts +2 -0
  13. package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.d.ts.map +1 -0
  14. package/dist/components/new-trace-viewer/components/trace-viewer-skeleton.js +19 -0
  15. package/dist/components/new-trace-viewer/components/use-row-window.d.ts +20 -0
  16. package/dist/components/new-trace-viewer/components/use-row-window.d.ts.map +1 -0
  17. package/dist/components/new-trace-viewer/components/use-row-window.js +69 -0
  18. package/dist/components/new-trace-viewer/context.d.ts.map +1 -1
  19. package/dist/components/new-trace-viewer/context.js +2 -2
  20. package/dist/components/new-trace-viewer/trace-viewer.d.ts +4 -1
  21. package/dist/components/new-trace-viewer/trace-viewer.d.ts.map +1 -1
  22. package/dist/components/new-trace-viewer/trace-viewer.js +19 -6
  23. package/dist/components/trace-viewer-new.d.ts +5 -1
  24. package/dist/components/trace-viewer-new.d.ts.map +1 -1
  25. package/dist/components/trace-viewer-new.js +7 -8
  26. package/dist/components/ui/icon-button.d.ts +8 -2
  27. package/dist/components/ui/icon-button.d.ts.map +1 -1
  28. package/dist/components/ui/icon-button.js +4 -4
  29. package/dist/components/ui/kbd.d.ts +6 -0
  30. package/dist/components/ui/kbd.d.ts.map +1 -0
  31. package/dist/components/ui/kbd.js +6 -0
  32. package/dist/components/ui/tooltip.d.ts +10 -0
  33. package/dist/components/ui/tooltip.d.ts.map +1 -0
  34. package/dist/components/ui/tooltip.js +12 -0
  35. package/dist/hooks/use-intersection-observer.d.ts +17 -0
  36. package/dist/hooks/use-intersection-observer.d.ts.map +1 -0
  37. package/dist/hooks/use-intersection-observer.js +29 -0
  38. package/dist/hooks/use-load-more-on-scroll.d.ts +21 -0
  39. package/dist/hooks/use-load-more-on-scroll.d.ts.map +1 -0
  40. package/dist/hooks/use-load-more-on-scroll.js +28 -0
  41. package/package.json +3 -3
  42. package/src/components/index.ts +1 -0
  43. package/src/components/new-trace-viewer/components/event-list.tsx +19 -11
  44. package/src/components/new-trace-viewer/components/split-pane.tsx +15 -2
  45. package/src/components/new-trace-viewer/components/timeline.tsx +27 -19
  46. package/src/components/new-trace-viewer/components/trace-viewer-skeleton.tsx +94 -0
  47. package/src/components/new-trace-viewer/components/use-row-window.ts +93 -0
  48. package/src/components/new-trace-viewer/context.tsx +1 -3
  49. package/src/components/new-trace-viewer/trace-viewer.tsx +89 -21
  50. package/src/components/trace-viewer-new.tsx +20 -12
  51. package/src/components/ui/icon-button.tsx +7 -11
  52. package/src/components/ui/kbd.tsx +21 -0
  53. package/src/components/ui/tooltip.tsx +47 -0
  54. package/src/hooks/use-intersection-observer.ts +57 -0
  55. package/src/hooks/use-load-more-on-scroll.ts +49 -0
@@ -0,0 +1,93 @@
1
+ 'use client';
2
+
3
+ import { type RefObject, useEffect, useState } from 'react';
4
+
5
+ /** Rows rendered on the first paint, before the container has been measured. */
6
+ const INITIAL_VISIBLE_ROWS = 60;
7
+
8
+ export interface RowWindow {
9
+ /** First visible row index (inclusive), already padded by overscan. */
10
+ start: number;
11
+ /** Last visible row index (exclusive), already padded by overscan. */
12
+ end: number;
13
+ }
14
+
15
+ /** Nearest scrollable ancestor of `el` (the shared `SplitPane` container). */
16
+ function getScrollParent(el: HTMLElement | null): HTMLElement | null {
17
+ let node = el?.parentElement ?? null;
18
+ while (node) {
19
+ const overflowY = getComputedStyle(node).overflowY;
20
+ if (overflowY === 'auto' || overflowY === 'scroll') return node;
21
+ node = node.parentElement;
22
+ }
23
+ return null;
24
+ }
25
+
26
+ /**
27
+ * Fixed-height windowing over a shared scroll container.
28
+ *
29
+ * The events list and timeline both render one `rowHeight`-tall row per span
30
+ * and scroll together inside the same `SplitPane`. Each caller passes a ref to
31
+ * its own root; the hook walks up to the shared scrollable ancestor, so the
32
+ * scroll container doesn't have to be threaded through props. Same scroll
33
+ * position + same row height means both panes window to the same range.
34
+ */
35
+ export function useRowWindow(
36
+ ref: RefObject<HTMLElement | null>,
37
+ rowCount: number,
38
+ rowHeight: number,
39
+ overscan = 12
40
+ ): RowWindow {
41
+ // Bound the pre-measurement window so the first paint of a large trace
42
+ // doesn't render every row before the effect narrows it down.
43
+ const [range, setRange] = useState<RowWindow>(() => ({
44
+ start: 0,
45
+ end: Math.min(rowCount, INITIAL_VISIBLE_ROWS),
46
+ }));
47
+
48
+ useEffect(() => {
49
+ const self = ref.current;
50
+ const el = getScrollParent(self);
51
+ if (!self || !el) return;
52
+
53
+ let raf = 0;
54
+ const measure = () => {
55
+ raf = 0;
56
+ // Offset of the list's top within the scroll container's content, so the
57
+ // window stays correct even if the list isn't flush with the top.
58
+ const offset =
59
+ self.getBoundingClientRect().top -
60
+ el.getBoundingClientRect().top +
61
+ el.scrollTop;
62
+ const top = el.scrollTop - offset;
63
+ const start = Math.max(0, Math.floor(top / rowHeight) - overscan);
64
+ const end = Math.min(
65
+ rowCount,
66
+ Math.ceil((top + el.clientHeight) / rowHeight) + overscan
67
+ );
68
+ setRange((prev) =>
69
+ prev.start === start && prev.end === end ? prev : { start, end }
70
+ );
71
+ };
72
+
73
+ const onScroll = () => {
74
+ if (!raf) raf = requestAnimationFrame(measure);
75
+ };
76
+
77
+ measure();
78
+ el.addEventListener('scroll', onScroll, { passive: true });
79
+ const ro = new ResizeObserver(measure);
80
+ ro.observe(el);
81
+
82
+ return () => {
83
+ el.removeEventListener('scroll', onScroll);
84
+ ro.disconnect();
85
+ if (raf) cancelAnimationFrame(raf);
86
+ };
87
+ }, [ref, rowCount, rowHeight, overscan]);
88
+
89
+ return range;
90
+ }
91
+
92
+ /** Row height (px) shared by the events list and timeline rows (`h-10`). */
93
+ export const ROW_HEIGHT_PX = 40;
@@ -28,9 +28,7 @@ export function ActiveSpanProvider({
28
28
  spans: Span[];
29
29
  children: ReactNode;
30
30
  }) {
31
- const [activeSpanId, setActiveSpanId] = useState<string | null>(
32
- spans[0]?.spanId ?? null
33
- );
31
+ const [activeSpanId, setActiveSpanId] = useState<string | null>(null);
34
32
 
35
33
  useEffect(() => {
36
34
  setActiveSpanId((currentSpanId) => {
@@ -19,6 +19,7 @@ import {
19
19
  useRef,
20
20
  useState,
21
21
  } from 'react';
22
+ import { useLoadMoreOnScroll } from '../../hooks/use-load-more-on-scroll';
22
23
  import { useReducedMotion } from '../../hooks/use-reduced-motion';
23
24
  import { ErrorBoundary } from '../error-boundary';
24
25
  import {
@@ -29,6 +30,8 @@ import { useSidebarDataOptional } from '../sidebar/sidebar-data-context';
29
30
  import type { Trace } from '../trace-viewer/types';
30
31
  import { formatDuration, getHighResInMs } from '../trace-viewer/util/timing';
31
32
  import { IconButton } from '../ui/icon-button';
33
+ import { Spinner } from '../ui/spinner';
34
+ import { Kbd } from '../ui/kbd';
32
35
  import EventList from './components/event-list';
33
36
  import { SplitPane } from './components/split-pane';
34
37
  import {
@@ -36,6 +39,12 @@ import {
36
39
  Timeline,
37
40
  TimelineHeader,
38
41
  } from './components/timeline';
42
+ import {
43
+ Tooltip,
44
+ TooltipContent,
45
+ TooltipProvider,
46
+ TooltipTrigger,
47
+ } from '../ui/tooltip';
39
48
  import { ActiveSpanProvider, useActiveSpan } from './context';
40
49
  import { DetailPanel } from './detail-panel';
41
50
  import { searchSpans } from './search';
@@ -43,6 +52,9 @@ import { computeRootBounds, computeTimeMarkers } from './utils';
43
52
 
44
53
  interface NewTraceViewerProps {
45
54
  trace: Trace;
55
+ onLoadMore?: () => void | Promise<void>;
56
+ hasMore?: boolean;
57
+ isLoadingMore?: boolean;
46
58
  }
47
59
 
48
60
  const MIN_VIEWPORT_MS = 0.001;
@@ -152,15 +164,32 @@ function useSelectedSpanInfo(): SelectedSpanInfo | null {
152
164
  // Root component
153
165
  // ---------------------------------------------------------------------------
154
166
 
155
- export function NewTraceViewer({ trace }: NewTraceViewerProps): ReactNode {
167
+ export function NewTraceViewer({
168
+ trace,
169
+ onLoadMore,
170
+ hasMore,
171
+ isLoadingMore,
172
+ }: NewTraceViewerProps): ReactNode {
156
173
  return (
157
- <ActiveSpanProvider spans={trace.spans}>
158
- <NewTraceViewerContent trace={trace} />
159
- </ActiveSpanProvider>
174
+ <TooltipProvider delayDuration={300}>
175
+ <ActiveSpanProvider spans={trace.spans}>
176
+ <NewTraceViewerContent
177
+ trace={trace}
178
+ onLoadMore={onLoadMore}
179
+ hasMore={hasMore}
180
+ isLoadingMore={isLoadingMore}
181
+ />
182
+ </ActiveSpanProvider>
183
+ </TooltipProvider>
160
184
  );
161
185
  }
162
186
 
163
- function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode {
187
+ function NewTraceViewerContent({
188
+ trace,
189
+ onLoadMore,
190
+ hasMore,
191
+ isLoadingMore,
192
+ }: NewTraceViewerProps): ReactNode {
164
193
  const { activeSpan, activeSpanId, setActiveSpan, clearActiveSpan } =
165
194
  useActiveSpan();
166
195
 
@@ -177,6 +206,16 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode {
177
206
 
178
207
  const root = useMemo(() => computeRootBounds(trace.spans), [trace.spans]);
179
208
 
209
+ const scrollContainerRef = useRef<HTMLDivElement>(null);
210
+ const loadMore = useCallback(() => {
211
+ void onLoadMore?.();
212
+ }, [onLoadMore]);
213
+ const loadMoreSentinelRef = useLoadMoreOnScroll(loadMore, {
214
+ hasMore: Boolean(onLoadMore && hasMore),
215
+ isLoadingMore: Boolean(isLoadingMore),
216
+ rootRef: scrollContainerRef,
217
+ });
218
+
180
219
  const { viewport, setViewport, animateTo } = useAnimatedViewport({
181
220
  start: root.startTime,
182
221
  end: root.startTime + root.duration,
@@ -530,6 +569,7 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode {
530
569
  className="grid grid-rows-[1fr] h-full min-h-0 overflow-hidden relative bg-background-100"
531
570
  >
532
571
  <SplitPane
572
+ scrollContainerRef={scrollContainerRef}
533
573
  startHeader={
534
574
  <div className="bg-background-100 border-b border-gray-alpha-400 h-10 min-h-10 flex items-center pl-4 pr-2 gap-1.5">
535
575
  <Search className="w-3.5 h-3.5 shrink-0 text-gray-800" />
@@ -575,6 +615,14 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode {
575
615
  searchResult={searchResult}
576
616
  onSelectSpan={handleSelectSpan}
577
617
  />
618
+ <div ref={loadMoreSentinelRef} className="flex justify-center">
619
+ {isLoadingMore ? (
620
+ <div className="flex items-center justify-center gap-2 py-3 text-sm text-gray-800">
621
+ <Spinner size={14} />
622
+ <span>Loading spans…</span>
623
+ </div>
624
+ ) : null}
625
+ </div>
578
626
  </div>
579
627
  {/* biome-ignore lint/a11y/noStaticElementInteractions: timeline hover and wheel gestures are pointer-only annotations */}
580
628
  <div
@@ -634,22 +682,42 @@ function NewTraceViewerContent({ trace }: NewTraceViewerProps): ReactNode {
634
682
  {selectedSpanName}
635
683
  </span>
636
684
  <div className="flex items-center gap-0.5 shrink-0">
637
- <IconButton
638
- aria-label="Navigate to previous span"
639
- aria-keyshortcuts="K"
640
- onClick={handleSelectPrevSpan}
641
- disabled={!prevSpanId}
642
- >
643
- <ChevronUp className="w-4 h-4" />
644
- </IconButton>
645
- <IconButton
646
- aria-label="Navigate to next span"
647
- aria-keyshortcuts="J"
648
- onClick={handleSelectNextSpan}
649
- disabled={!nextSpanId}
650
- >
651
- <ChevronDown className="w-4 h-4" />
652
- </IconButton>
685
+ <Tooltip>
686
+ <TooltipTrigger asChild>
687
+ <IconButton
688
+ aria-label="Navigate up"
689
+ aria-keyshortcuts="K"
690
+ onClick={handleSelectPrevSpan}
691
+ disabled={!prevSpanId}
692
+ >
693
+ <ChevronUp className="w-4 h-4" />
694
+ </IconButton>
695
+ </TooltipTrigger>
696
+ {prevSpanId ? (
697
+ <TooltipContent>
698
+ Navigate up
699
+ <Kbd>K</Kbd>
700
+ </TooltipContent>
701
+ ) : null}
702
+ </Tooltip>
703
+ <Tooltip>
704
+ <TooltipTrigger asChild>
705
+ <IconButton
706
+ aria-label="Navigate down"
707
+ aria-keyshortcuts="J"
708
+ onClick={handleSelectNextSpan}
709
+ disabled={!nextSpanId}
710
+ >
711
+ <ChevronDown className="w-4 h-4" />
712
+ </IconButton>
713
+ </TooltipTrigger>
714
+ {nextSpanId ? (
715
+ <TooltipContent>
716
+ Navigate down
717
+ <Kbd>J</Kbd>
718
+ </TooltipContent>
719
+ ) : null}
720
+ </Tooltip>
653
721
  <div aria-hidden className="w-px h-4 bg-gray-alpha-400 mx-1" />
654
722
  <IconButton
655
723
  aria-label="Close span details"
@@ -1,11 +1,12 @@
1
- import { useMemo } from 'react';
2
1
  import type { WorkflowRun } from '@workflow/core/runtime';
3
2
  import type { Event } from '@workflow/world';
3
+ import { useMemo } from 'react';
4
4
  import { buildTrace, type TraceWithMeta } from '../lib/trace-builder';
5
+ import { TraceViewerSkeleton } from './new-trace-viewer/components/trace-viewer-skeleton';
5
6
  import { NewTraceViewer as NewTraceViewerComponent } from './new-trace-viewer/trace-viewer';
6
7
  import {
7
- SidebarDataProvider,
8
8
  type SidebarDataContextValue,
9
+ SidebarDataProvider,
9
10
  } from './sidebar/sidebar-data-context';
10
11
  import type { Trace } from './trace-viewer/types';
11
12
 
@@ -13,34 +14,41 @@ const NewTraceViewer = ({
13
14
  run,
14
15
  events,
15
16
  sidebarData,
17
+ onLoadMore,
18
+ hasMore,
19
+ isLoadingMore,
20
+ loading = false,
16
21
  }: {
17
22
  run: WorkflowRun;
18
23
  events: Event[];
19
24
  sidebarData: SidebarDataContextValue;
25
+ onLoadMore?: () => void | Promise<void>;
26
+ hasMore?: boolean;
27
+ isLoadingMore?: boolean;
28
+ loading?: boolean;
20
29
  }) => {
21
- // Build trace only when actual data changes — no timer-driven rebuilds.
22
- // Active span widths are animated imperatively by useLiveTick at 60fps.
23
30
  const traceWithMeta: TraceWithMeta | undefined = useMemo(() => {
24
31
  if (!run?.runId) {
25
32
  return undefined;
26
33
  }
27
34
  return buildTrace(run, events, new Date());
28
- // eslint-disable-next-line react-hooks/exhaustive-deps -- `new Date()` is intentionally not a dep; useLiveTick handles live growth
35
+ // eslint-disable-next-line react-hooks/exhaustive-deps -- `new Date()` is intentionally not a dep
29
36
  }, [run, events]);
30
37
  const trace = traceWithMeta;
31
38
 
32
- if (!trace) {
33
- return (
34
- <div className="relative w-full h-full flex items-center justify-center">
35
- <div className="text-gray-500 text-sm">Loading trace…</div>
36
- </div>
37
- );
39
+ if (!trace || (loading && events.length === 0)) {
40
+ return <TraceViewerSkeleton />;
38
41
  }
39
42
 
40
43
  return (
41
44
  <SidebarDataProvider value={sidebarData}>
42
45
  <div className="relative w-full h-full flex">
43
- <NewTraceViewerComponent trace={trace as Trace} />
46
+ <NewTraceViewerComponent
47
+ trace={trace as Trace}
48
+ onLoadMore={onLoadMore}
49
+ hasMore={hasMore}
50
+ isLoadingMore={isLoadingMore}
51
+ />
44
52
  </div>
45
53
  </SidebarDataProvider>
46
54
  );
@@ -1,5 +1,5 @@
1
1
  import { cva, type VariantProps } from 'class-variance-authority';
2
- import type * as React from 'react';
2
+ import * as React from 'react';
3
3
  import { cn } from '../../lib/utils';
4
4
 
5
5
  const iconButtonVariants = cva(
@@ -38,20 +38,16 @@ export type IconButtonProps = Omit<
38
38
  type?: 'button' | 'submit' | 'reset';
39
39
  };
40
40
 
41
- export function IconButton({
42
- className,
43
- variant,
44
- size,
45
- type = 'button',
46
- ...props
47
- }: IconButtonProps) {
48
- return (
41
+ export const IconButton = React.forwardRef<HTMLButtonElement, IconButtonProps>(
42
+ ({ className, variant, size, type = 'button', ...props }, ref) => (
49
43
  <button
44
+ ref={ref}
50
45
  type={type}
51
46
  className={cn(iconButtonVariants({ variant, size, className }))}
52
47
  {...props}
53
48
  />
54
- );
55
- }
49
+ )
50
+ );
51
+ IconButton.displayName = 'IconButton';
56
52
 
57
53
  export { iconButtonVariants };
@@ -0,0 +1,21 @@
1
+ import type { ReactNode } from 'react';
2
+ import { cn } from '../../lib/utils';
3
+
4
+ export function Kbd({
5
+ children,
6
+ className,
7
+ }: {
8
+ children: ReactNode;
9
+ className?: string;
10
+ }): ReactNode {
11
+ return (
12
+ <kbd
13
+ className={cn(
14
+ 'inline-flex h-5 min-w-5 items-center justify-center rounded bg-background-100/20 px-1 text-center font-sans text-xs leading-none',
15
+ className
16
+ )}
17
+ >
18
+ {children}
19
+ </kbd>
20
+ );
21
+ }
@@ -0,0 +1,47 @@
1
+ 'use client';
2
+
3
+ import * as TooltipPrimitive from '@radix-ui/react-tooltip';
4
+ import * as React from 'react';
5
+ import { cn } from '../../lib/utils';
6
+
7
+ const TooltipProvider = TooltipPrimitive.Provider;
8
+
9
+ const Tooltip = TooltipPrimitive.Root;
10
+
11
+ const TooltipTrigger = TooltipPrimitive.Trigger;
12
+
13
+ const TooltipContent = React.forwardRef<
14
+ React.ElementRef<typeof TooltipPrimitive.Content>,
15
+ React.ComponentPropsWithoutRef<typeof TooltipPrimitive.Content> & {
16
+ showArrow?: boolean;
17
+ }
18
+ >(
19
+ (
20
+ { className, sideOffset = 8, showArrow = true, children, ...props },
21
+ ref
22
+ ) => (
23
+ <TooltipPrimitive.Portal>
24
+ <TooltipPrimitive.Content
25
+ ref={ref}
26
+ sideOffset={sideOffset}
27
+ className={cn(
28
+ 'z-[99999] flex items-center gap-1 rounded-[10px] bg-gray-1000 px-2 py-1.5 !text-label-13 text-background-100 shadow-md select-none',
29
+ className
30
+ )}
31
+ {...props}
32
+ >
33
+ {children}
34
+ {showArrow ? (
35
+ <TooltipPrimitive.Arrow
36
+ width={11}
37
+ height={5}
38
+ className="fill-gray-1000"
39
+ />
40
+ ) : null}
41
+ </TooltipPrimitive.Content>
42
+ </TooltipPrimitive.Portal>
43
+ )
44
+ );
45
+ TooltipContent.displayName = TooltipPrimitive.Content.displayName;
46
+
47
+ export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider };
@@ -0,0 +1,57 @@
1
+ 'use client';
2
+
3
+ import type { RefObject } from 'react';
4
+ import { useEffect, useState } from 'react';
5
+
6
+ interface UseIntersectionObserverArgs
7
+ extends Omit<IntersectionObserverInit, 'root'> {
8
+ /**
9
+ * Scroll container the target is observed within. Defaults to the viewport.
10
+ * Passed as a ref so it resolves after commit (when the node exists).
11
+ */
12
+ rootRef?: RefObject<Element | null>;
13
+ freezeOnceVisible?: boolean;
14
+ }
15
+
16
+ /**
17
+ * Returns the latest IntersectionObserverEntry for `elementRef`.
18
+ * Ported from vercel/front's `@vercel/hooks` `useIntersectionObserver`, with
19
+ * `root` threaded as a ref so we can observe inside a custom scroll container.
20
+ */
21
+ export function useIntersectionObserver(
22
+ elementRef: RefObject<Element | null>,
23
+ {
24
+ threshold = 0,
25
+ rootRef,
26
+ rootMargin = '0%',
27
+ freezeOnceVisible = false,
28
+ }: UseIntersectionObserverArgs = {}
29
+ ): IntersectionObserverEntry | undefined {
30
+ const [entry, setEntry] = useState<IntersectionObserverEntry>();
31
+
32
+ const frozen = entry?.isIntersecting && freezeOnceVisible;
33
+
34
+ useEffect(() => {
35
+ const node = elementRef?.current;
36
+
37
+ const hasIOSupport = !!window.IntersectionObserver;
38
+
39
+ if (!hasIOSupport || frozen || !node) return;
40
+
41
+ const updateEntry = ([entry]: IntersectionObserverEntry[]): void => {
42
+ setEntry(entry);
43
+ };
44
+
45
+ const observer = new IntersectionObserver(updateEntry, {
46
+ threshold,
47
+ root: rootRef?.current ?? null,
48
+ rootMargin,
49
+ });
50
+
51
+ observer.observe(node);
52
+
53
+ return () => observer.disconnect();
54
+ }, [elementRef, threshold, rootRef, rootMargin, frozen]);
55
+
56
+ return entry;
57
+ }
@@ -0,0 +1,49 @@
1
+ 'use client';
2
+
3
+ import type { RefObject } from 'react';
4
+ import { useEffect, useRef } from 'react';
5
+ import { useIntersectionObserver } from './use-intersection-observer';
6
+
7
+ interface UseLoadMoreOnScrollOptions {
8
+ hasMore: boolean;
9
+ isLoadingMore: boolean;
10
+ /** Scroll container the sentinel lives in. Defaults to the viewport. */
11
+ rootRef?: RefObject<Element | null>;
12
+ rootMargin?: string;
13
+ }
14
+
15
+ /**
16
+ * Triggers `loadMore` when a sentinel element scrolls into view.
17
+ * Returns a ref to attach to an invisible sentinel `<div>` placed after the
18
+ * list content. Ported from vercel/front's `@vercel/hooks`
19
+ * `useLoadMoreOnScroll`.
20
+ *
21
+ * Because it keys off `isIntersecting`/`isLoadingMore`, it self-continues:
22
+ * if the sentinel is still in view once a page finishes loading, the next
23
+ * page is requested automatically.
24
+ */
25
+ export function useLoadMoreOnScroll(
26
+ loadMore: () => void,
27
+ {
28
+ hasMore,
29
+ isLoadingMore,
30
+ rootRef,
31
+ rootMargin = '400px',
32
+ }: UseLoadMoreOnScrollOptions
33
+ ) {
34
+ const sentinelRef = useRef<HTMLDivElement>(null);
35
+ const entry = useIntersectionObserver(sentinelRef, { rootRef, rootMargin });
36
+
37
+ const loadMoreRef = useRef(loadMore);
38
+ useEffect(() => {
39
+ loadMoreRef.current = loadMore;
40
+ }, [loadMore]);
41
+
42
+ useEffect(() => {
43
+ if (entry?.isIntersecting && hasMore && !isLoadingMore) {
44
+ loadMoreRef.current();
45
+ }
46
+ }, [entry?.isIntersecting, hasMore, isLoadingMore]);
47
+
48
+ return sentinelRef;
49
+ }