react-fathom 0.1.10 → 0.2.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 (80) hide show
  1. package/README.md +941 -25
  2. package/dist/cjs/index.cjs +55 -9
  3. package/dist/cjs/index.cjs.map +1 -1
  4. package/dist/cjs/native/index.cjs +1079 -0
  5. package/dist/cjs/native/index.cjs.map +1 -0
  6. package/dist/cjs/next/index.cjs +89 -5
  7. package/dist/cjs/next/index.cjs.map +1 -1
  8. package/dist/es/index.js +55 -9
  9. package/dist/es/index.js.map +1 -1
  10. package/dist/es/native/index.js +1071 -0
  11. package/dist/es/native/index.js.map +1 -0
  12. package/dist/es/next/index.js +90 -6
  13. package/dist/es/next/index.js.map +1 -1
  14. package/dist/react-fathom.js +55 -9
  15. package/dist/react-fathom.js.map +1 -1
  16. package/dist/react-fathom.min.js +2 -2
  17. package/dist/react-fathom.min.js.map +1 -1
  18. package/package.json +28 -5
  19. package/src/FathomContext.tsx +30 -1
  20. package/src/FathomProvider.test.tsx +115 -15
  21. package/src/FathomProvider.tsx +10 -2
  22. package/src/components/TrackClick.test.tsx +7 -7
  23. package/src/components/TrackClick.tsx +1 -1
  24. package/src/components/TrackVisible.test.tsx +7 -7
  25. package/src/components/TrackVisible.tsx +1 -1
  26. package/src/hooks/useFathom.test.tsx +14 -3
  27. package/src/hooks/useTrackOnClick.test.tsx +4 -4
  28. package/src/hooks/useTrackOnClick.ts +1 -1
  29. package/src/hooks/useTrackOnVisible.test.tsx +4 -4
  30. package/src/hooks/useTrackOnVisible.ts +1 -1
  31. package/src/index.ts +1 -0
  32. package/src/native/FathomWebView.test.tsx +410 -0
  33. package/src/native/FathomWebView.tsx +297 -0
  34. package/src/native/NativeFathomProvider.test.tsx +372 -0
  35. package/src/native/NativeFathomProvider.tsx +113 -0
  36. package/src/native/createWebViewClient.test.ts +380 -0
  37. package/src/native/createWebViewClient.ts +271 -0
  38. package/src/native/index.ts +29 -0
  39. package/src/native/react-native.d.ts +74 -0
  40. package/src/native/types.ts +145 -0
  41. package/src/native/useAppStateTracking.test.ts +249 -0
  42. package/src/native/useAppStateTracking.ts +66 -0
  43. package/src/native/useNavigationTracking.test.ts +446 -0
  44. package/src/native/useNavigationTracking.ts +177 -0
  45. package/src/next/NextFathomProviderApp.client.tsx +5 -0
  46. package/src/next/NextFathomProviderApp.test.tsx +154 -0
  47. package/src/next/NextFathomProviderApp.tsx +62 -0
  48. package/src/next/index.ts +3 -0
  49. package/src/types.ts +36 -9
  50. package/types/FathomContext.d.ts +1 -1
  51. package/types/FathomContext.d.ts.map +1 -1
  52. package/types/FathomProvider.d.ts.map +1 -1
  53. package/types/components/TrackClick.d.ts +1 -1
  54. package/types/components/TrackVisible.d.ts +1 -1
  55. package/types/hooks/useTrackOnClick.d.ts +1 -1
  56. package/types/hooks/useTrackOnVisible.d.ts +1 -1
  57. package/types/index.d.ts +1 -0
  58. package/types/index.d.ts.map +1 -1
  59. package/types/native/FathomWebView.d.ts +59 -0
  60. package/types/native/FathomWebView.d.ts.map +1 -0
  61. package/types/native/NativeFathomProvider.d.ts +36 -0
  62. package/types/native/NativeFathomProvider.d.ts.map +1 -0
  63. package/types/native/createWebViewClient.d.ts +51 -0
  64. package/types/native/createWebViewClient.d.ts.map +1 -0
  65. package/types/native/index.d.ts +10 -0
  66. package/types/native/index.d.ts.map +1 -0
  67. package/types/native/types.d.ts +125 -0
  68. package/types/native/types.d.ts.map +1 -0
  69. package/types/native/useAppStateTracking.d.ts +25 -0
  70. package/types/native/useAppStateTracking.d.ts.map +1 -0
  71. package/types/native/useNavigationTracking.d.ts +30 -0
  72. package/types/native/useNavigationTracking.d.ts.map +1 -0
  73. package/types/next/NextFathomProviderApp.client.d.ts +3 -0
  74. package/types/next/NextFathomProviderApp.client.d.ts.map +1 -0
  75. package/types/next/NextFathomProviderApp.d.ts +38 -4
  76. package/types/next/NextFathomProviderApp.d.ts.map +1 -1
  77. package/types/next/index.d.ts +1 -0
  78. package/types/next/index.d.ts.map +1 -1
  79. package/types/types.d.ts +34 -9
  80. package/types/types.d.ts.map +1 -1
@@ -0,0 +1,372 @@
1
+ import React, { useRef } from 'react'
2
+
3
+ import { describe, expect, it, vi, beforeEach } from 'vitest'
4
+ import { render, screen, waitFor } from '@testing-library/react'
5
+
6
+ import { useFathom } from '../hooks/useFathom'
7
+ import { NativeFathomProvider } from './NativeFathomProvider'
8
+ import type { WebViewFathomClient } from './createWebViewClient'
9
+
10
+ // Mock the FathomWebView component
11
+ vi.mock('./FathomWebView', () => ({
12
+ FathomWebView: vi.fn(({ onReady, onError, siteId, debug }) => {
13
+ // Store the callbacks for testing
14
+ ;(global as any).__fathomWebViewProps = { onReady, onError, siteId, debug }
15
+ return null
16
+ }),
17
+ }))
18
+
19
+ // Mock react-native AppState for useAppStateTracking
20
+ vi.mock('react-native', () => ({
21
+ AppState: {
22
+ currentState: 'active',
23
+ addEventListener: vi.fn(() => ({ remove: vi.fn() })),
24
+ },
25
+ StyleSheet: {
26
+ create: vi.fn((styles) => styles),
27
+ },
28
+ View: vi.fn(({ children }) => children),
29
+ }))
30
+
31
+ describe('NativeFathomProvider', () => {
32
+ beforeEach(() => {
33
+ vi.clearAllMocks()
34
+ ;(global as any).__fathomWebViewProps = null
35
+ })
36
+
37
+ it('should render children', () => {
38
+ render(
39
+ <NativeFathomProvider siteId="TEST_SITE">
40
+ <div data-testid="child">Child content</div>
41
+ </NativeFathomProvider>,
42
+ )
43
+
44
+ expect(screen.getByTestId('child')).toBeDefined()
45
+ expect(screen.getByText('Child content')).toBeDefined()
46
+ })
47
+
48
+ it('should pass siteId to FathomWebView', async () => {
49
+ const { FathomWebView } = await import('./FathomWebView')
50
+
51
+ render(
52
+ <NativeFathomProvider siteId="MY_SITE_ID">
53
+ <div>Test</div>
54
+ </NativeFathomProvider>,
55
+ )
56
+
57
+ expect(FathomWebView).toHaveBeenCalled()
58
+ const callArgs = (FathomWebView as any).mock.calls[0][0]
59
+ expect(callArgs.siteId).toBe('MY_SITE_ID')
60
+ })
61
+
62
+ it('should pass debug prop to FathomWebView', async () => {
63
+ const { FathomWebView } = await import('./FathomWebView')
64
+
65
+ render(
66
+ <NativeFathomProvider siteId="TEST_SITE" debug={true}>
67
+ <div>Test</div>
68
+ </NativeFathomProvider>,
69
+ )
70
+
71
+ expect(FathomWebView).toHaveBeenCalled()
72
+ const callArgs = (FathomWebView as any).mock.calls[0][0]
73
+ expect(callArgs.debug).toBe(true)
74
+ })
75
+
76
+ it('should pass scriptDomain to FathomWebView', async () => {
77
+ const { FathomWebView } = await import('./FathomWebView')
78
+
79
+ render(
80
+ <NativeFathomProvider siteId="TEST_SITE" scriptDomain="custom.domain.com">
81
+ <div>Test</div>
82
+ </NativeFathomProvider>,
83
+ )
84
+
85
+ expect(FathomWebView).toHaveBeenCalled()
86
+ const callArgs = (FathomWebView as any).mock.calls[0][0]
87
+ expect(callArgs.scriptDomain).toBe('custom.domain.com')
88
+ })
89
+
90
+ it('should pass loadOptions to FathomWebView', async () => {
91
+ const { FathomWebView } = await import('./FathomWebView')
92
+ const loadOptions = { honorDNT: true, auto: false }
93
+
94
+ render(
95
+ <NativeFathomProvider siteId="TEST_SITE" loadOptions={loadOptions}>
96
+ <div>Test</div>
97
+ </NativeFathomProvider>,
98
+ )
99
+
100
+ expect(FathomWebView).toHaveBeenCalled()
101
+ const callArgs = (FathomWebView as any).mock.calls[0][0]
102
+ expect(callArgs.loadOptions).toEqual(loadOptions)
103
+ })
104
+
105
+ it('should call onReady when FathomWebView is ready', async () => {
106
+ const onReady = vi.fn()
107
+
108
+ render(
109
+ <NativeFathomProvider siteId="TEST_SITE" onReady={onReady}>
110
+ <div>Test</div>
111
+ </NativeFathomProvider>,
112
+ )
113
+
114
+ // Simulate WebView ready
115
+ const props = (global as any).__fathomWebViewProps
116
+ props?.onReady?.()
117
+
118
+ expect(onReady).toHaveBeenCalled()
119
+ })
120
+
121
+ it('should call onError when FathomWebView has an error', async () => {
122
+ const onError = vi.fn()
123
+
124
+ render(
125
+ <NativeFathomProvider siteId="TEST_SITE" onError={onError}>
126
+ <div>Test</div>
127
+ </NativeFathomProvider>,
128
+ )
129
+
130
+ // Simulate WebView error
131
+ const props = (global as any).__fathomWebViewProps
132
+ props?.onError?.('Test error')
133
+
134
+ expect(onError).toHaveBeenCalledWith('Test error')
135
+ })
136
+
137
+ it('should provide Fathom context to children', () => {
138
+ const TestChild = () => {
139
+ const fathom = useFathom()
140
+ return (
141
+ <div data-testid="has-context">
142
+ {fathom.trackEvent ? 'has trackEvent' : 'no trackEvent'}
143
+ </div>
144
+ )
145
+ }
146
+
147
+ render(
148
+ <NativeFathomProvider siteId="TEST_SITE">
149
+ <TestChild />
150
+ </NativeFathomProvider>,
151
+ )
152
+
153
+ expect(screen.getByText('has trackEvent')).toBeDefined()
154
+ })
155
+
156
+ it('should pass defaultPageviewOptions to FathomProvider', () => {
157
+ const TestChild = () => {
158
+ const { defaultPageviewOptions } = useFathom()
159
+ return (
160
+ <div data-testid="default-options">
161
+ {defaultPageviewOptions?.referrer || 'no referrer'}
162
+ </div>
163
+ )
164
+ }
165
+
166
+ render(
167
+ <NativeFathomProvider
168
+ siteId="TEST_SITE"
169
+ defaultPageviewOptions={{ referrer: 'https://example.com' }}
170
+ >
171
+ <TestChild />
172
+ </NativeFathomProvider>,
173
+ )
174
+
175
+ expect(screen.getByText('https://example.com')).toBeDefined()
176
+ })
177
+
178
+ it('should pass defaultEventOptions to FathomProvider', () => {
179
+ const TestChild = () => {
180
+ const { defaultEventOptions } = useFathom()
181
+ return (
182
+ <div data-testid="default-options">
183
+ {(defaultEventOptions as any)?._site_id || 'no site id'}
184
+ </div>
185
+ )
186
+ }
187
+
188
+ render(
189
+ <NativeFathomProvider
190
+ siteId="TEST_SITE"
191
+ defaultEventOptions={{ _site_id: 'my-app' } as any}
192
+ >
193
+ <TestChild />
194
+ </NativeFathomProvider>,
195
+ )
196
+
197
+ expect(screen.getByText('my-app')).toBeDefined()
198
+ })
199
+
200
+ describe('trackAppState prop', () => {
201
+ it('should not render AppStateTracker when trackAppState is false', async () => {
202
+ const reactNative = await import('react-native')
203
+
204
+ render(
205
+ <NativeFathomProvider siteId="TEST_SITE" trackAppState={false}>
206
+ <div>Test</div>
207
+ </NativeFathomProvider>,
208
+ )
209
+
210
+ // AppState.addEventListener should not be called for app state tracking
211
+ // (It might be called once for other reasons, so we just verify it works)
212
+ expect(screen.getByText('Test')).toBeDefined()
213
+ })
214
+
215
+ it('should render AppStateTracker when trackAppState is true', async () => {
216
+ const reactNative = await import('react-native')
217
+
218
+ render(
219
+ <NativeFathomProvider siteId="TEST_SITE" trackAppState={true}>
220
+ <div>Test</div>
221
+ </NativeFathomProvider>,
222
+ )
223
+
224
+ // AppState.addEventListener should be called for app state tracking
225
+ expect(reactNative.AppState.addEventListener).toHaveBeenCalledWith(
226
+ 'change',
227
+ expect.any(Function),
228
+ )
229
+ })
230
+ })
231
+
232
+ describe('client methods', () => {
233
+ it('should provide a working trackEvent method', () => {
234
+ const trackEventCalls: any[] = []
235
+
236
+ const TestChild = () => {
237
+ const { trackEvent } = useFathom()
238
+ React.useEffect(() => {
239
+ trackEvent('test-event', { _value: 100 })
240
+ }, [trackEvent])
241
+ return null
242
+ }
243
+
244
+ render(
245
+ <NativeFathomProvider siteId="TEST_SITE">
246
+ <TestChild />
247
+ </NativeFathomProvider>,
248
+ )
249
+
250
+ // The event should be queued since WebView isn't ready
251
+ // We just verify no errors are thrown
252
+ })
253
+
254
+ it('should provide a working trackPageview method', () => {
255
+ const TestChild = () => {
256
+ const { trackPageview } = useFathom()
257
+ React.useEffect(() => {
258
+ trackPageview({ url: '/test-page' })
259
+ }, [trackPageview])
260
+ return null
261
+ }
262
+
263
+ render(
264
+ <NativeFathomProvider siteId="TEST_SITE">
265
+ <TestChild />
266
+ </NativeFathomProvider>,
267
+ )
268
+
269
+ // The pageview should be queued since WebView isn't ready
270
+ // We just verify no errors are thrown
271
+ })
272
+
273
+ it('should provide a working trackGoal method', () => {
274
+ const TestChild = () => {
275
+ const { trackGoal } = useFathom()
276
+ React.useEffect(() => {
277
+ trackGoal('PURCHASE', 2999)
278
+ }, [trackGoal])
279
+ return null
280
+ }
281
+
282
+ render(
283
+ <NativeFathomProvider siteId="TEST_SITE">
284
+ <TestChild />
285
+ </NativeFathomProvider>,
286
+ )
287
+
288
+ // The goal should be queued since WebView isn't ready
289
+ // We just verify no errors are thrown
290
+ })
291
+ })
292
+
293
+ describe('clientRef', () => {
294
+ it('should populate clientRef with the WebViewFathomClient', async () => {
295
+ let clientRefValue: WebViewFathomClient | null = null
296
+
297
+ const TestComponent = () => {
298
+ const clientRef = useRef<WebViewFathomClient>(null)
299
+ React.useEffect(() => {
300
+ clientRefValue = clientRef.current
301
+ })
302
+ return (
303
+ <NativeFathomProvider siteId="TEST_SITE" clientRef={clientRef}>
304
+ <div>Test</div>
305
+ </NativeFathomProvider>
306
+ )
307
+ }
308
+
309
+ render(<TestComponent />)
310
+
311
+ await waitFor(() => {
312
+ expect(clientRefValue).not.toBeNull()
313
+ expect(clientRefValue).toBeDefined()
314
+ })
315
+
316
+ // Verify it's a WebViewFathomClient with the extended methods
317
+ expect(typeof clientRefValue?.trackEvent).toBe('function')
318
+ expect(typeof clientRefValue?.trackPageview).toBe('function')
319
+ expect(typeof clientRefValue?.processQueue).toBe('function')
320
+ expect(typeof clientRefValue?.getQueueLength).toBe('function')
321
+ expect(typeof clientRefValue?.setWebViewReady).toBe('function')
322
+ })
323
+
324
+ it('should allow parent to call client methods via clientRef', async () => {
325
+ const clientRef = React.createRef<WebViewFathomClient>() as React.MutableRefObject<WebViewFathomClient | null>
326
+ clientRef.current = null
327
+
328
+ render(
329
+ <NativeFathomProvider siteId="TEST_SITE" clientRef={clientRef}>
330
+ <div>Test</div>
331
+ </NativeFathomProvider>,
332
+ )
333
+
334
+ await waitFor(() => {
335
+ expect(clientRef.current).not.toBeNull()
336
+ })
337
+
338
+ // Parent can use the client directly
339
+ // Events will be queued since WebView isn't ready
340
+ clientRef.current?.trackEvent('parent-event', { _value: 100 })
341
+
342
+ // Verify the event was queued
343
+ expect(clientRef.current?.getQueueLength()).toBeGreaterThan(0)
344
+ })
345
+
346
+ it('should allow parent to check queue status via clientRef', async () => {
347
+ const clientRef = React.createRef<WebViewFathomClient>() as React.MutableRefObject<WebViewFathomClient | null>
348
+ clientRef.current = null
349
+
350
+ render(
351
+ <NativeFathomProvider siteId="TEST_SITE" clientRef={clientRef}>
352
+ <div>Test</div>
353
+ </NativeFathomProvider>,
354
+ )
355
+
356
+ await waitFor(() => {
357
+ expect(clientRef.current).not.toBeNull()
358
+ })
359
+
360
+ // Initially, queue should be empty
361
+ expect(clientRef.current?.getQueueLength()).toBe(0)
362
+
363
+ // Track some events (they'll be queued)
364
+ clientRef.current?.trackEvent('event-1')
365
+ clientRef.current?.trackPageview({ url: '/page-1' })
366
+ clientRef.current?.trackGoal('GOAL', 100)
367
+
368
+ // Queue should now have 3 items
369
+ expect(clientRef.current?.getQueueLength()).toBe(3)
370
+ })
371
+ })
372
+ })
@@ -0,0 +1,113 @@
1
+ import React, { useMemo, useRef, useCallback, useEffect } from 'react'
2
+
3
+ import { FathomProvider } from '../FathomProvider'
4
+ import { FathomWebView, type FathomWebViewRef } from './FathomWebView'
5
+ import { createWebViewClient, type WebViewFathomClient } from './createWebViewClient'
6
+ import { useAppStateTracking } from './useAppStateTracking'
7
+ import type { NativeFathomProviderProps } from './types'
8
+
9
+ /**
10
+ * Internal component that handles app state tracking
11
+ */
12
+ const AppStateTracker: React.FC = () => {
13
+ useAppStateTracking({
14
+ foregroundEventName: 'app-foreground',
15
+ backgroundEventName: 'app-background',
16
+ })
17
+
18
+ return null
19
+ }
20
+
21
+ /**
22
+ * A convenience provider for React Native apps that uses a hidden WebView
23
+ * to load the official Fathom Analytics script.
24
+ *
25
+ * This approach ensures full compatibility with Fathom's tracking by using
26
+ * their official JavaScript client, while providing a native React API.
27
+ *
28
+ * @example
29
+ * ```tsx
30
+ * import { NativeFathomProvider } from 'react-fathom/native'
31
+ *
32
+ * function App() {
33
+ * return (
34
+ * <NativeFathomProvider
35
+ * siteId="YOUR_SITE_ID"
36
+ * debug={__DEV__}
37
+ * trackAppState
38
+ * >
39
+ * <YourApp />
40
+ * </NativeFathomProvider>
41
+ * )
42
+ * }
43
+ * ```
44
+ *
45
+ * @remarks
46
+ * This component renders a hidden WebView that loads Fathom's tracking script.
47
+ * Events are queued until the WebView is ready, then automatically flushed.
48
+ *
49
+ * The WebView approach is used because Fathom Analytics does not currently
50
+ * provide a public API for server-side or mobile event tracking. This ensures
51
+ * your analytics are recorded correctly using Fathom's official client.
52
+ */
53
+ export const NativeFathomProvider: React.FC<NativeFathomProviderProps> = ({
54
+ siteId,
55
+ loadOptions,
56
+ scriptDomain,
57
+ defaultPageviewOptions,
58
+ defaultEventOptions,
59
+ trackAppState = false,
60
+ debug = false,
61
+ onReady,
62
+ onError,
63
+ clientRef,
64
+ children,
65
+ }) => {
66
+ const webViewRef = useRef<FathomWebViewRef>(null)
67
+
68
+ // Create the WebView-based client
69
+ const client = useMemo(
70
+ (): WebViewFathomClient =>
71
+ createWebViewClient(() => webViewRef.current, {
72
+ debug,
73
+ enableQueue: true,
74
+ maxQueueSize: 100,
75
+ }),
76
+ [debug],
77
+ )
78
+
79
+ // Populate the clientRef so the parent component can access the client
80
+ useEffect(() => {
81
+ if (clientRef) {
82
+ clientRef.current = client
83
+ }
84
+ }, [client, clientRef])
85
+
86
+ // Handle WebView ready event
87
+ const handleReady = useCallback(() => {
88
+ // Flush any queued commands
89
+ client.setWebViewReady()
90
+ onReady?.()
91
+ }, [client, onReady])
92
+
93
+ return (
94
+ <FathomProvider
95
+ client={client}
96
+ siteId={siteId}
97
+ defaultPageviewOptions={defaultPageviewOptions}
98
+ defaultEventOptions={defaultEventOptions}
99
+ >
100
+ <FathomWebView
101
+ ref={webViewRef}
102
+ siteId={siteId}
103
+ loadOptions={loadOptions}
104
+ scriptDomain={scriptDomain}
105
+ debug={debug}
106
+ onReady={handleReady}
107
+ onError={onError}
108
+ />
109
+ {trackAppState && <AppStateTracker />}
110
+ {children}
111
+ </FathomProvider>
112
+ )
113
+ }