react-fathom 0.1.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.
Files changed (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +199 -0
  3. package/dist/cjs/index.cjs +410 -0
  4. package/dist/cjs/index.cjs.map +1 -0
  5. package/dist/cjs/next/index.cjs +910 -0
  6. package/dist/cjs/next/index.cjs.map +1 -0
  7. package/dist/es/index.js +381 -0
  8. package/dist/es/index.js.map +1 -0
  9. package/dist/es/next/index.js +885 -0
  10. package/dist/es/next/index.js.map +1 -0
  11. package/dist/react-fathom.js +413 -0
  12. package/dist/react-fathom.js.map +1 -0
  13. package/dist/react-fathom.min.js +3 -0
  14. package/dist/react-fathom.min.js.map +1 -0
  15. package/package.json +127 -0
  16. package/src/FathomContext.tsx +5 -0
  17. package/src/FathomProvider.test.tsx +532 -0
  18. package/src/FathomProvider.tsx +122 -0
  19. package/src/components/TrackClick.test.tsx +191 -0
  20. package/src/components/TrackClick.tsx +62 -0
  21. package/src/components/TrackPageview.test.tsx +111 -0
  22. package/src/components/TrackPageview.tsx +36 -0
  23. package/src/components/TrackVisible.test.tsx +311 -0
  24. package/src/components/TrackVisible.tsx +105 -0
  25. package/src/components/index.ts +3 -0
  26. package/src/hooks/index.ts +4 -0
  27. package/src/hooks/useFathom.test.tsx +51 -0
  28. package/src/hooks/useFathom.ts +11 -0
  29. package/src/hooks/useTrackOnClick.test.tsx +197 -0
  30. package/src/hooks/useTrackOnClick.ts +65 -0
  31. package/src/hooks/useTrackOnMount.test.tsx +79 -0
  32. package/src/hooks/useTrackOnMount.ts +24 -0
  33. package/src/hooks/useTrackOnVisible.test.tsx +313 -0
  34. package/src/hooks/useTrackOnVisible.ts +99 -0
  35. package/src/index.ts +4 -0
  36. package/src/next/NextFathomProvider.test.tsx +131 -0
  37. package/src/next/NextFathomProvider.tsx +62 -0
  38. package/src/next/NextFathomProviderApp.test.tsx +308 -0
  39. package/src/next/NextFathomProviderApp.tsx +106 -0
  40. package/src/next/NextFathomProviderPages.test.tsx +330 -0
  41. package/src/next/NextFathomProviderPages.tsx +112 -0
  42. package/src/next/compositions/withAppRouter.test.tsx +113 -0
  43. package/src/next/compositions/withAppRouter.tsx +48 -0
  44. package/src/next/compositions/withPagesRouter.test.tsx +113 -0
  45. package/src/next/compositions/withPagesRouter.tsx +44 -0
  46. package/src/next/index.ts +7 -0
  47. package/src/next/types.ts +19 -0
  48. package/src/types.ts +37 -0
  49. package/types/FathomContext.d.ts +3 -0
  50. package/types/FathomContext.d.ts.map +1 -0
  51. package/types/FathomProvider.d.ts +5 -0
  52. package/types/FathomProvider.d.ts.map +1 -0
  53. package/types/components/TrackClick.d.ts +39 -0
  54. package/types/components/TrackClick.d.ts.map +1 -0
  55. package/types/components/TrackPageview.d.ts +21 -0
  56. package/types/components/TrackPageview.d.ts.map +1 -0
  57. package/types/components/TrackVisible.d.ts +39 -0
  58. package/types/components/TrackVisible.d.ts.map +1 -0
  59. package/types/components/index.d.ts +4 -0
  60. package/types/components/index.d.ts.map +1 -0
  61. package/types/hooks/index.d.ts +5 -0
  62. package/types/hooks/index.d.ts.map +1 -0
  63. package/types/hooks/useFathom.d.ts +6 -0
  64. package/types/hooks/useFathom.d.ts.map +1 -0
  65. package/types/hooks/useTrackOnClick.d.ts +39 -0
  66. package/types/hooks/useTrackOnClick.d.ts.map +1 -0
  67. package/types/hooks/useTrackOnMount.d.ts +14 -0
  68. package/types/hooks/useTrackOnMount.d.ts.map +1 -0
  69. package/types/hooks/useTrackOnVisible.d.ts +43 -0
  70. package/types/hooks/useTrackOnVisible.d.ts.map +1 -0
  71. package/types/index.d.ts +5 -0
  72. package/types/index.d.ts.map +1 -0
  73. package/types/next/AppRouterProvider.d.ts +7 -0
  74. package/types/next/AppRouterProvider.d.ts.map +1 -0
  75. package/types/next/NextFathomProvider.d.ts +34 -0
  76. package/types/next/NextFathomProvider.d.ts.map +1 -0
  77. package/types/next/NextFathomProviderApp.d.ts +6 -0
  78. package/types/next/NextFathomProviderApp.d.ts.map +1 -0
  79. package/types/next/NextFathomProviderPages.d.ts +6 -0
  80. package/types/next/NextFathomProviderPages.d.ts.map +1 -0
  81. package/types/next/PagesRouterProvider.d.ts +7 -0
  82. package/types/next/PagesRouterProvider.d.ts.map +1 -0
  83. package/types/next/compositions/withAppRouter.d.ts +29 -0
  84. package/types/next/compositions/withAppRouter.d.ts.map +1 -0
  85. package/types/next/compositions/withPagesRouter.d.ts +25 -0
  86. package/types/next/compositions/withPagesRouter.d.ts.map +1 -0
  87. package/types/next/index.d.ts +6 -0
  88. package/types/next/index.d.ts.map +1 -0
  89. package/types/next/types.d.ts +16 -0
  90. package/types/next/types.d.ts.map +1 -0
  91. package/types/test-setup.d.ts +2 -0
  92. package/types/test-setup.d.ts.map +1 -0
  93. package/types/types.d.ts +34 -0
  94. package/types/types.d.ts.map +1 -0
  95. package/types/useFathom.d.ts +7 -0
  96. package/types/useFathom.d.ts.map +1 -0
@@ -0,0 +1,191 @@
1
+ import React from 'react'
2
+
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ import { fireEvent, render, screen } from '@testing-library/react'
6
+
7
+ import { FathomProvider } from '../FathomProvider'
8
+ import { TrackClick } from './TrackClick'
9
+
10
+ describe('TrackClick', () => {
11
+ const mockTrackEvent = vi.fn()
12
+ const mockClient = {
13
+ trackEvent: mockTrackEvent,
14
+ trackPageview: vi.fn(),
15
+ trackGoal: vi.fn(),
16
+ load: vi.fn(),
17
+ setSite: vi.fn(),
18
+ blockTrackingForMe: vi.fn(),
19
+ enableTrackingForMe: vi.fn(),
20
+ isTrackingEnabled: vi.fn(() => true),
21
+ }
22
+
23
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
24
+ <FathomProvider client={mockClient}>{children}</FathomProvider>
25
+ )
26
+
27
+ beforeEach(() => {
28
+ vi.clearAllMocks()
29
+ })
30
+
31
+ it('should render children', () => {
32
+ render(
33
+ <TrackClick eventName="test-event">
34
+ <button>Click me</button>
35
+ </TrackClick>,
36
+ { wrapper },
37
+ )
38
+
39
+ expect(screen.getByText('Click me')).toBeInTheDocument()
40
+ })
41
+
42
+ it('should track event on click', () => {
43
+ render(
44
+ <TrackClick eventName="test-event">
45
+ <button>Click me</button>
46
+ </TrackClick>,
47
+ { wrapper },
48
+ )
49
+
50
+ fireEvent.click(screen.getByText('Click me'))
51
+
52
+ expect(mockTrackEvent).toHaveBeenCalledTimes(1)
53
+ expect(mockTrackEvent).toHaveBeenCalledWith('test-event', {})
54
+ })
55
+
56
+ it('should track event with options', () => {
57
+ render(
58
+ <TrackClick eventName="test-event" id="test-id" value={100}>
59
+ <button>Click me</button>
60
+ </TrackClick>,
61
+ { wrapper },
62
+ )
63
+
64
+ fireEvent.click(screen.getByText('Click me'))
65
+
66
+ expect(mockTrackEvent).toHaveBeenCalledWith('test-event', {
67
+ id: 'test-id',
68
+ value: 100,
69
+ })
70
+ })
71
+
72
+ it('should prevent default when preventDefault is true', () => {
73
+ render(
74
+ <TrackClick eventName="test-event" preventDefault>
75
+ <a href="/test">Link</a>
76
+ </TrackClick>,
77
+ { wrapper },
78
+ )
79
+
80
+ const link = screen.getByText('Link')
81
+ const clickEvent = new MouseEvent('click', {
82
+ bubbles: true,
83
+ cancelable: true,
84
+ })
85
+ const preventDefaultSpy = vi.spyOn(clickEvent, 'preventDefault')
86
+
87
+ fireEvent(link, clickEvent)
88
+
89
+ expect(preventDefaultSpy).toHaveBeenCalled()
90
+ expect(mockTrackEvent).toHaveBeenCalledTimes(1)
91
+ })
92
+
93
+ it('should not prevent default when preventDefault is false', () => {
94
+ render(
95
+ <TrackClick eventName="test-event" preventDefault={false}>
96
+ <a href="/test">Link</a>
97
+ </TrackClick>,
98
+ { wrapper },
99
+ )
100
+
101
+ const link = screen.getByText('Link')
102
+ const clickEvent = new MouseEvent('click', {
103
+ bubbles: true,
104
+ cancelable: true,
105
+ })
106
+ const preventDefaultSpy = vi.spyOn(clickEvent, 'preventDefault')
107
+
108
+ fireEvent(link, clickEvent)
109
+
110
+ expect(preventDefaultSpy).not.toHaveBeenCalled()
111
+ expect(mockTrackEvent).toHaveBeenCalledTimes(1)
112
+ })
113
+
114
+ it('should call custom onClick handler', () => {
115
+ const onClick = vi.fn()
116
+
117
+ render(
118
+ <TrackClick eventName="test-event" onClick={onClick}>
119
+ <button>Click me</button>
120
+ </TrackClick>,
121
+ { wrapper },
122
+ )
123
+
124
+ fireEvent.click(screen.getByText('Click me'))
125
+
126
+ expect(onClick).toHaveBeenCalledTimes(1)
127
+ expect(mockTrackEvent).toHaveBeenCalledTimes(1)
128
+ })
129
+
130
+ it('should render with custom element type', () => {
131
+ render(
132
+ <TrackClick eventName="test-event" as="span">
133
+ <button>Click me</button>
134
+ </TrackClick>,
135
+ { wrapper },
136
+ )
137
+
138
+ const wrapperElement = screen.getByText('Click me').parentElement
139
+ expect(wrapperElement?.tagName).toBe('SPAN')
140
+ })
141
+
142
+ it('should render with default div wrapper', () => {
143
+ render(
144
+ <TrackClick eventName="test-event">
145
+ <button>Click me</button>
146
+ </TrackClick>,
147
+ { wrapper },
148
+ )
149
+
150
+ const wrapperElement = screen.getByText('Click me').parentElement
151
+ expect(wrapperElement?.tagName).toBe('DIV')
152
+ })
153
+
154
+ it('should handle multiple children', () => {
155
+ render(
156
+ <TrackClick eventName="test-event">
157
+ <button>Button 1</button>
158
+ <button>Button 2</button>
159
+ </TrackClick>,
160
+ { wrapper },
161
+ )
162
+
163
+ expect(screen.getByText('Button 1')).toBeInTheDocument()
164
+ expect(screen.getByText('Button 2')).toBeInTheDocument()
165
+ })
166
+
167
+ it('should merge defaultEventOptions from provider', () => {
168
+ const customWrapper = ({ children }: { children: React.ReactNode }) => (
169
+ <FathomProvider
170
+ client={mockClient}
171
+ defaultEventOptions={{ id: 'default-id' }}
172
+ >
173
+ {children}
174
+ </FathomProvider>
175
+ )
176
+
177
+ render(
178
+ <TrackClick eventName="test-event" value={100}>
179
+ <button>Click me</button>
180
+ </TrackClick>,
181
+ { wrapper: customWrapper },
182
+ )
183
+
184
+ fireEvent.click(screen.getByText('Click me'))
185
+
186
+ expect(mockTrackEvent).toHaveBeenCalledWith('test-event', {
187
+ id: 'default-id',
188
+ value: 100,
189
+ })
190
+ })
191
+ })
@@ -0,0 +1,62 @@
1
+ import React from 'react'
2
+ import type { ElementType, MouseEvent, ReactNode } from 'react'
3
+
4
+ import type { EventOptions } from 'fathom-client'
5
+
6
+ import { useFathom } from '../hooks/useFathom'
7
+
8
+ export interface TrackClickProps extends EventOptions {
9
+ /**
10
+ * Event name to track
11
+ */
12
+ eventName: string
13
+ /**
14
+ * Child element(s) to wrap
15
+ */
16
+ children: ReactNode
17
+ /**
18
+ * Whether to prevent default behavior
19
+ * @default false
20
+ */
21
+ preventDefault?: boolean
22
+ /**
23
+ * Custom onClick handler (will be called before tracking)
24
+ */
25
+ onClick?: (e: MouseEvent) => void
26
+ /**
27
+ * HTML element to render as wrapper
28
+ * @default 'div'
29
+ */
30
+ as?: ElementType
31
+ }
32
+
33
+ /**
34
+ * Component wrapper that automatically tracks clicks on its children
35
+ *
36
+ * @example
37
+ * ```tsx
38
+ * <TrackClick eventName="cta-clicked" id="hero-cta">
39
+ * <button>Get Started</button>
40
+ * </TrackClick>
41
+ * ```
42
+ */
43
+ export const TrackClick: React.FC<TrackClickProps> = ({
44
+ eventName,
45
+ children,
46
+ preventDefault = false,
47
+ onClick,
48
+ as: Component = 'div',
49
+ ...eventOptions
50
+ }) => {
51
+ const { trackEvent } = useFathom()
52
+
53
+ const handleClick = (e: MouseEvent) => {
54
+ if (preventDefault) {
55
+ e.preventDefault()
56
+ }
57
+ onClick?.(e)
58
+ trackEvent?.(eventName, eventOptions)
59
+ }
60
+
61
+ return <Component onClick={handleClick}>{children}</Component>
62
+ }
@@ -0,0 +1,111 @@
1
+ import React from 'react'
2
+
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ import { render, screen } from '@testing-library/react'
6
+
7
+ import { FathomProvider } from '../FathomProvider'
8
+ import { TrackPageview } from './TrackPageview'
9
+
10
+ describe('TrackPageview', () => {
11
+ const mockTrackPageview = vi.fn()
12
+ const mockClient = {
13
+ trackEvent: vi.fn(),
14
+ trackPageview: mockTrackPageview,
15
+ trackGoal: vi.fn(),
16
+ load: vi.fn(),
17
+ setSite: vi.fn(),
18
+ blockTrackingForMe: vi.fn(),
19
+ enableTrackingForMe: vi.fn(),
20
+ isTrackingEnabled: vi.fn(() => true),
21
+ }
22
+
23
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
24
+ <FathomProvider client={mockClient}>{children}</FathomProvider>
25
+ )
26
+
27
+ beforeEach(() => {
28
+ vi.clearAllMocks()
29
+ })
30
+
31
+ it('should track pageview on mount', () => {
32
+ render(<TrackPageview />, { wrapper })
33
+
34
+ expect(mockTrackPageview).toHaveBeenCalledTimes(1)
35
+ expect(mockTrackPageview).toHaveBeenCalledWith({})
36
+ })
37
+
38
+ it('should track pageview with options', () => {
39
+ render(<TrackPageview url="/test-page" />, { wrapper })
40
+
41
+ expect(mockTrackPageview).toHaveBeenCalledTimes(1)
42
+ expect(mockTrackPageview).toHaveBeenCalledWith({ url: '/test-page' })
43
+ })
44
+
45
+ it('should render children', () => {
46
+ render(
47
+ <TrackPageview>
48
+ <div>Page content</div>
49
+ </TrackPageview>,
50
+ { wrapper },
51
+ )
52
+
53
+ expect(screen.getByText('Page content')).toBeInTheDocument()
54
+ expect(mockTrackPageview).toHaveBeenCalledTimes(1)
55
+ })
56
+
57
+ it('should render without children', () => {
58
+ const { container } = render(<TrackPageview />, { wrapper })
59
+
60
+ expect(container.firstChild).toBeNull()
61
+ expect(mockTrackPageview).toHaveBeenCalledTimes(1)
62
+ })
63
+
64
+ it('should only track once on mount', () => {
65
+ const { rerender } = render(<TrackPageview />, { wrapper })
66
+
67
+ expect(mockTrackPageview).toHaveBeenCalledTimes(1)
68
+
69
+ rerender(<TrackPageview />)
70
+ expect(mockTrackPageview).toHaveBeenCalledTimes(1)
71
+ })
72
+
73
+ it('should merge defaultPageviewOptions from provider', () => {
74
+ const customWrapper = ({ children }: { children: React.ReactNode }) => (
75
+ <FathomProvider
76
+ client={mockClient}
77
+ defaultPageviewOptions={{ url: '/default' }}
78
+ >
79
+ {children}
80
+ </FathomProvider>
81
+ )
82
+
83
+ render(<TrackPageview />, { wrapper: customWrapper })
84
+
85
+ expect(mockTrackPageview).toHaveBeenCalledTimes(1)
86
+ // The component passes empty object, provider merges defaults internally
87
+ expect(mockTrackPageview).toHaveBeenCalledWith({ url: '/default' })
88
+ })
89
+
90
+ it('should override defaultPageviewOptions with props', () => {
91
+ const customWrapper = ({ children }: { children: React.ReactNode }) => (
92
+ <FathomProvider
93
+ client={mockClient}
94
+ defaultPageviewOptions={{ url: '/default' }}
95
+ >
96
+ {children}
97
+ </FathomProvider>
98
+ )
99
+
100
+ render(<TrackPageview url="/custom" />, { wrapper: customWrapper })
101
+
102
+ expect(mockTrackPageview).toHaveBeenCalledTimes(1)
103
+ expect(mockTrackPageview).toHaveBeenCalledWith({ url: '/custom' })
104
+ })
105
+
106
+ it('should not track when used outside FathomProvider', () => {
107
+ render(<TrackPageview />)
108
+
109
+ expect(mockTrackPageview).not.toHaveBeenCalled()
110
+ })
111
+ })
@@ -0,0 +1,36 @@
1
+ import React, { useEffect } from 'react'
2
+ import type { ReactNode } from 'react'
3
+
4
+ import type { PageViewOptions } from 'fathom-client'
5
+
6
+ import { useFathom } from '../hooks/useFathom'
7
+
8
+ export interface TrackPageviewProps extends PageViewOptions {
9
+ /**
10
+ * Child element(s) to render
11
+ */
12
+ children?: ReactNode
13
+ }
14
+
15
+ /**
16
+ * Component that tracks a pageview when it mounts
17
+ *
18
+ * @example
19
+ * ```tsx
20
+ * <TrackPageview url="/custom-page">
21
+ * <div>Page content</div>
22
+ * </TrackPageview>
23
+ * ```
24
+ */
25
+ export const TrackPageview: React.FC<TrackPageviewProps> = ({
26
+ children,
27
+ ...pageviewOptions
28
+ }) => {
29
+ const { trackPageview } = useFathom()
30
+
31
+ useEffect(() => {
32
+ trackPageview?.(pageviewOptions)
33
+ }, []) // eslint-disable-line react-hooks/exhaustive-deps
34
+
35
+ return <>{children}</>
36
+ }
@@ -0,0 +1,311 @@
1
+ import React from 'react'
2
+
3
+ import { describe, expect, it, vi, beforeEach, afterEach } from 'vitest'
4
+
5
+ import { render, screen, waitFor } from '@testing-library/react'
6
+
7
+ import { FathomProvider } from '../FathomProvider'
8
+ import { TrackVisible } from './TrackVisible'
9
+
10
+ describe('TrackVisible', () => {
11
+ const mockTrackEvent = vi.fn()
12
+ const mockClient = {
13
+ trackEvent: mockTrackEvent,
14
+ trackPageview: vi.fn(),
15
+ trackGoal: vi.fn(),
16
+ load: vi.fn(),
17
+ setSite: vi.fn(),
18
+ blockTrackingForMe: vi.fn(),
19
+ enableTrackingForMe: vi.fn(),
20
+ isTrackingEnabled: vi.fn(() => true),
21
+ }
22
+
23
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
24
+ <FathomProvider client={mockClient}>{children}</FathomProvider>
25
+ )
26
+
27
+ let mockObserve: ReturnType<typeof vi.fn>
28
+ let mockDisconnect: ReturnType<typeof vi.fn>
29
+ let observerCallback: (entries: IntersectionObserverEntry[]) => void
30
+
31
+ beforeEach(() => {
32
+ vi.clearAllMocks()
33
+ mockObserve = vi.fn()
34
+ mockDisconnect = vi.fn()
35
+
36
+ global.IntersectionObserver = class MockIntersectionObserver {
37
+ observe = mockObserve
38
+ disconnect = mockDisconnect
39
+ unobserve = vi.fn()
40
+
41
+ constructor(callback: (entries: IntersectionObserverEntry[]) => void) {
42
+ observerCallback = callback
43
+ }
44
+ } as any
45
+ })
46
+
47
+ afterEach(() => {
48
+ delete (global as any).IntersectionObserver
49
+ })
50
+
51
+ it('should render children', () => {
52
+ render(
53
+ <TrackVisible eventName="test-event">
54
+ <div>Visible content</div>
55
+ </TrackVisible>,
56
+ { wrapper },
57
+ )
58
+
59
+ expect(screen.getByText('Visible content')).toBeInTheDocument()
60
+ })
61
+
62
+ it('should create IntersectionObserver on mount', () => {
63
+ render(
64
+ <TrackVisible eventName="test-event">
65
+ <div>Content</div>
66
+ </TrackVisible>,
67
+ { wrapper },
68
+ )
69
+
70
+ expect(mockObserve).toHaveBeenCalled()
71
+ })
72
+
73
+ it('should track event when element becomes visible', async () => {
74
+ render(
75
+ <TrackVisible eventName="test-event">
76
+ <div>Content</div>
77
+ </TrackVisible>,
78
+ { wrapper },
79
+ )
80
+
81
+ // Wait for IntersectionObserver to be created
82
+ await waitFor(() => {
83
+ expect(mockObserve).toHaveBeenCalled()
84
+ })
85
+
86
+ const element = screen.getByText('Content').parentElement
87
+ const mockEntry = {
88
+ isIntersecting: true,
89
+ target: element!,
90
+ } as IntersectionObserverEntry
91
+
92
+ observerCallback([mockEntry])
93
+
94
+ await waitFor(() => {
95
+ expect(mockTrackEvent).toHaveBeenCalledTimes(1)
96
+ expect(mockTrackEvent).toHaveBeenCalledWith('test-event', {})
97
+ })
98
+ })
99
+
100
+ it('should track event with options', async () => {
101
+ render(
102
+ <TrackVisible eventName="test-event" id="test-id" value={100}>
103
+ <div>Content</div>
104
+ </TrackVisible>,
105
+ { wrapper },
106
+ )
107
+
108
+ // Wait for IntersectionObserver to be created
109
+ await waitFor(() => {
110
+ expect(mockObserve).toHaveBeenCalled()
111
+ })
112
+
113
+ const element = screen.getByText('Content').parentElement
114
+ const mockEntry = {
115
+ isIntersecting: true,
116
+ target: element!,
117
+ } as IntersectionObserverEntry
118
+
119
+ observerCallback([mockEntry])
120
+
121
+ await waitFor(() => {
122
+ expect(mockTrackEvent).toHaveBeenCalledWith('test-event', {
123
+ id: 'test-id',
124
+ value: 100,
125
+ })
126
+ })
127
+ })
128
+
129
+ it('should track only once when trackOnce is true', async () => {
130
+ render(
131
+ <TrackVisible eventName="test-event" trackOnce>
132
+ <div>Content</div>
133
+ </TrackVisible>,
134
+ { wrapper },
135
+ )
136
+
137
+ // Wait for IntersectionObserver to be created
138
+ await waitFor(() => {
139
+ expect(mockObserve).toHaveBeenCalled()
140
+ })
141
+
142
+ const element = screen.getByText('Content').parentElement
143
+ const mockEntry = {
144
+ isIntersecting: true,
145
+ target: element!,
146
+ } as IntersectionObserverEntry
147
+
148
+ observerCallback([mockEntry])
149
+ observerCallback([mockEntry])
150
+ observerCallback([mockEntry])
151
+
152
+ await waitFor(() => {
153
+ expect(mockTrackEvent).toHaveBeenCalledTimes(1)
154
+ })
155
+ })
156
+
157
+ it('should track multiple times when trackOnce is false', async () => {
158
+ render(
159
+ <TrackVisible eventName="test-event" trackOnce={false}>
160
+ <div>Content</div>
161
+ </TrackVisible>,
162
+ { wrapper },
163
+ )
164
+
165
+ // Wait for IntersectionObserver to be created
166
+ await waitFor(() => {
167
+ expect(mockObserve).toHaveBeenCalled()
168
+ })
169
+
170
+ const element = screen.getByText('Content').parentElement
171
+ const mockEntry = {
172
+ isIntersecting: true,
173
+ target: element!,
174
+ } as IntersectionObserverEntry
175
+
176
+ observerCallback([mockEntry])
177
+ observerCallback([mockEntry])
178
+ observerCallback([mockEntry])
179
+
180
+ await waitFor(() => {
181
+ expect(mockTrackEvent).toHaveBeenCalledTimes(3)
182
+ })
183
+ })
184
+
185
+ it('should use custom observer options', async () => {
186
+ const observerOptions = { threshold: 0.5 }
187
+ let capturedOptions: IntersectionObserverInit | undefined
188
+
189
+ const OriginalObserver = global.IntersectionObserver
190
+ global.IntersectionObserver = class MockIntersectionObserver {
191
+ observe = mockObserve
192
+ disconnect = mockDisconnect
193
+ unobserve = vi.fn()
194
+
195
+ constructor(
196
+ callback: (entries: IntersectionObserverEntry[]) => void,
197
+ options?: IntersectionObserverInit,
198
+ ) {
199
+ capturedOptions = options
200
+ observerCallback = callback
201
+ }
202
+ } as any
203
+
204
+ render(
205
+ <TrackVisible eventName="test-event" observerOptions={observerOptions}>
206
+ <div>Content</div>
207
+ </TrackVisible>,
208
+ { wrapper },
209
+ )
210
+
211
+ await waitFor(() => {
212
+ expect(capturedOptions?.threshold).toBe(0.5)
213
+ })
214
+
215
+ global.IntersectionObserver = OriginalObserver
216
+ })
217
+
218
+ it('should render with custom element type', () => {
219
+ render(
220
+ <TrackVisible eventName="test-event" as="section">
221
+ <div>Content</div>
222
+ </TrackVisible>,
223
+ { wrapper },
224
+ )
225
+
226
+ const wrapperElement = screen.getByText('Content').parentElement
227
+ expect(wrapperElement?.tagName).toBe('SECTION')
228
+ })
229
+
230
+ it('should render with default div wrapper', () => {
231
+ render(
232
+ <TrackVisible eventName="test-event">
233
+ <div>Content</div>
234
+ </TrackVisible>,
235
+ { wrapper },
236
+ )
237
+
238
+ const wrapperElement = screen.getByText('Content').parentElement
239
+ expect(wrapperElement?.tagName).toBe('DIV')
240
+ })
241
+
242
+ it('should forward ref', () => {
243
+ const ref = { current: null }
244
+
245
+ render(
246
+ <TrackVisible eventName="test-event" ref={ref}>
247
+ <div>Content</div>
248
+ </TrackVisible>,
249
+ { wrapper },
250
+ )
251
+
252
+ expect(ref.current).not.toBeNull()
253
+ expect(ref.current).toBeInstanceOf(HTMLDivElement)
254
+ })
255
+
256
+ it('should disconnect observer on unmount', async () => {
257
+ const { unmount } = render(
258
+ <TrackVisible eventName="test-event">
259
+ <div>Content</div>
260
+ </TrackVisible>,
261
+ { wrapper },
262
+ )
263
+
264
+ // Wait for IntersectionObserver to be created
265
+ await waitFor(() => {
266
+ expect(mockObserve).toHaveBeenCalled()
267
+ })
268
+
269
+ unmount()
270
+
271
+ expect(mockDisconnect).toHaveBeenCalledTimes(1)
272
+ })
273
+
274
+ it('should merge defaultEventOptions from provider', async () => {
275
+ const customWrapper = ({ children }: { children: React.ReactNode }) => (
276
+ <FathomProvider
277
+ client={mockClient}
278
+ defaultEventOptions={{ id: 'default-id' }}
279
+ >
280
+ {children}
281
+ </FathomProvider>
282
+ )
283
+
284
+ render(
285
+ <TrackVisible eventName="test-event" value={100}>
286
+ <div>Content</div>
287
+ </TrackVisible>,
288
+ { wrapper: customWrapper },
289
+ )
290
+
291
+ // Wait for IntersectionObserver to be created
292
+ await waitFor(() => {
293
+ expect(mockObserve).toHaveBeenCalled()
294
+ })
295
+
296
+ const element = screen.getByText('Content').parentElement
297
+ const mockEntry = {
298
+ isIntersecting: true,
299
+ target: element!,
300
+ } as IntersectionObserverEntry
301
+
302
+ observerCallback([mockEntry])
303
+
304
+ await waitFor(() => {
305
+ expect(mockTrackEvent).toHaveBeenCalledWith('test-event', {
306
+ id: 'default-id',
307
+ value: 100,
308
+ })
309
+ })
310
+ })
311
+ })