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,410 @@
1
+ import React, { createRef, act } from 'react'
2
+
3
+ import { describe, expect, it, vi, beforeEach } from 'vitest'
4
+ import { render } from '@testing-library/react'
5
+
6
+ import { FathomWebView, type FathomWebViewRef } from './FathomWebView'
7
+
8
+ // Store mock WebView instance for testing
9
+ let mockWebViewInstance: {
10
+ injectJavaScript: ReturnType<typeof vi.fn>
11
+ onMessage?: (event: any) => void
12
+ onError?: (event: any) => void
13
+ source?: { html: string }
14
+ javaScriptEnabled?: boolean
15
+ } | null = null
16
+
17
+ // Mock react-native
18
+ vi.mock('react-native', () => ({
19
+ StyleSheet: {
20
+ create: vi.fn((styles) => styles),
21
+ },
22
+ View: ({ children }: { children?: React.ReactNode }) => (
23
+ <div data-testid="rn-view">{children}</div>
24
+ ),
25
+ }))
26
+
27
+ // Mock react-native-webview
28
+ vi.mock('react-native-webview', () => {
29
+ const MockWebView = React.forwardRef((props: any, ref: any) => {
30
+ const injectJavaScript = vi.fn()
31
+
32
+ // Store for testing
33
+ mockWebViewInstance = {
34
+ injectJavaScript,
35
+ onMessage: props.onMessage,
36
+ onError: props.onError,
37
+ source: props.source,
38
+ javaScriptEnabled: props.javaScriptEnabled,
39
+ }
40
+
41
+ // Expose injectJavaScript via ref
42
+ React.useImperativeHandle(ref, () => ({
43
+ injectJavaScript,
44
+ }))
45
+
46
+ return (
47
+ <div
48
+ data-testid="webview"
49
+ data-source={JSON.stringify(props.source)}
50
+ data-javascript-enabled={props.javaScriptEnabled}
51
+ />
52
+ )
53
+ })
54
+
55
+ return { WebView: MockWebView }
56
+ })
57
+
58
+ describe('FathomWebView', () => {
59
+ beforeEach(() => {
60
+ vi.clearAllMocks()
61
+ mockWebViewInstance = null
62
+ })
63
+
64
+ describe('rendering', () => {
65
+ it('should render a WebView', () => {
66
+ const { getByTestId } = render(<FathomWebView siteId="TEST_SITE" />)
67
+
68
+ expect(getByTestId('webview')).toBeDefined()
69
+ })
70
+
71
+ it('should include siteId in the HTML source', () => {
72
+ const { getByTestId } = render(
73
+ <FathomWebView siteId="MY_CUSTOM_SITE_ID" />,
74
+ )
75
+
76
+ const webview = getByTestId('webview')
77
+ const source = JSON.parse(webview.getAttribute('data-source') || '{}')
78
+
79
+ expect(source.html).toContain('data-site="MY_CUSTOM_SITE_ID"')
80
+ })
81
+
82
+ it('should use default scriptDomain', () => {
83
+ const { getByTestId } = render(<FathomWebView siteId="TEST_SITE" />)
84
+
85
+ const webview = getByTestId('webview')
86
+ const source = JSON.parse(webview.getAttribute('data-source') || '{}')
87
+
88
+ expect(source.html).toContain('cdn.usefathom.com/script.js')
89
+ })
90
+
91
+ it('should use custom scriptDomain when provided', () => {
92
+ const { getByTestId } = render(
93
+ <FathomWebView siteId="TEST_SITE" scriptDomain="custom.domain.com" />,
94
+ )
95
+
96
+ const webview = getByTestId('webview')
97
+ const source = JSON.parse(webview.getAttribute('data-source') || '{}')
98
+
99
+ expect(source.html).toContain('custom.domain.com/script.js')
100
+ })
101
+
102
+ it('should enable JavaScript', () => {
103
+ const { getByTestId } = render(<FathomWebView siteId="TEST_SITE" />)
104
+
105
+ const webview = getByTestId('webview')
106
+ expect(webview.getAttribute('data-javascript-enabled')).toBe('true')
107
+ })
108
+ })
109
+
110
+ describe('ref methods', () => {
111
+ it('should expose trackPageview method', () => {
112
+ const ref = createRef<FathomWebViewRef>()
113
+
114
+ render(<FathomWebView ref={ref} siteId="TEST_SITE" />)
115
+
116
+ expect(ref.current?.trackPageview).toBeDefined()
117
+ expect(typeof ref.current?.trackPageview).toBe('function')
118
+ })
119
+
120
+ it('should expose trackEvent method', () => {
121
+ const ref = createRef<FathomWebViewRef>()
122
+
123
+ render(<FathomWebView ref={ref} siteId="TEST_SITE" />)
124
+
125
+ expect(ref.current?.trackEvent).toBeDefined()
126
+ expect(typeof ref.current?.trackEvent).toBe('function')
127
+ })
128
+
129
+ it('should expose trackGoal method', () => {
130
+ const ref = createRef<FathomWebViewRef>()
131
+
132
+ render(<FathomWebView ref={ref} siteId="TEST_SITE" />)
133
+
134
+ expect(ref.current?.trackGoal).toBeDefined()
135
+ expect(typeof ref.current?.trackGoal).toBe('function')
136
+ })
137
+
138
+ it('should expose blockTrackingForMe method', () => {
139
+ const ref = createRef<FathomWebViewRef>()
140
+
141
+ render(<FathomWebView ref={ref} siteId="TEST_SITE" />)
142
+
143
+ expect(ref.current?.blockTrackingForMe).toBeDefined()
144
+ expect(typeof ref.current?.blockTrackingForMe).toBe('function')
145
+ })
146
+
147
+ it('should expose enableTrackingForMe method', () => {
148
+ const ref = createRef<FathomWebViewRef>()
149
+
150
+ render(<FathomWebView ref={ref} siteId="TEST_SITE" />)
151
+
152
+ expect(ref.current?.enableTrackingForMe).toBeDefined()
153
+ expect(typeof ref.current?.enableTrackingForMe).toBe('function')
154
+ })
155
+
156
+ it('should expose isReady method', () => {
157
+ const ref = createRef<FathomWebViewRef>()
158
+
159
+ render(<FathomWebView ref={ref} siteId="TEST_SITE" />)
160
+
161
+ expect(ref.current?.isReady).toBeDefined()
162
+ expect(typeof ref.current?.isReady).toBe('function')
163
+ })
164
+
165
+ it('should return false from isReady before ready message', () => {
166
+ const ref = createRef<FathomWebViewRef>()
167
+
168
+ render(<FathomWebView ref={ref} siteId="TEST_SITE" />)
169
+
170
+ expect(ref.current?.isReady()).toBe(false)
171
+ })
172
+ })
173
+
174
+ describe('JavaScript injection', () => {
175
+ it('should inject trackPageview command', () => {
176
+ const ref = createRef<FathomWebViewRef>()
177
+
178
+ render(<FathomWebView ref={ref} siteId="TEST_SITE" />)
179
+
180
+ ref.current?.trackPageview({ url: '/test-page' })
181
+
182
+ expect(mockWebViewInstance?.injectJavaScript).toHaveBeenCalledWith(
183
+ expect.stringContaining('trackPageview'),
184
+ )
185
+ expect(mockWebViewInstance?.injectJavaScript).toHaveBeenCalledWith(
186
+ expect.stringContaining('/test-page'),
187
+ )
188
+ })
189
+
190
+ it('should inject trackEvent command', () => {
191
+ const ref = createRef<FathomWebViewRef>()
192
+
193
+ render(<FathomWebView ref={ref} siteId="TEST_SITE" />)
194
+
195
+ ref.current?.trackEvent('button-click', { _value: 100 })
196
+
197
+ expect(mockWebViewInstance?.injectJavaScript).toHaveBeenCalledWith(
198
+ expect.stringContaining('trackEvent'),
199
+ )
200
+ expect(mockWebViewInstance?.injectJavaScript).toHaveBeenCalledWith(
201
+ expect.stringContaining('button-click'),
202
+ )
203
+ })
204
+
205
+ it('should inject trackGoal command', () => {
206
+ const ref = createRef<FathomWebViewRef>()
207
+
208
+ render(<FathomWebView ref={ref} siteId="TEST_SITE" />)
209
+
210
+ ref.current?.trackGoal('PURCHASE', 2999)
211
+
212
+ expect(mockWebViewInstance?.injectJavaScript).toHaveBeenCalledWith(
213
+ expect.stringContaining('trackGoal'),
214
+ )
215
+ expect(mockWebViewInstance?.injectJavaScript).toHaveBeenCalledWith(
216
+ expect.stringContaining('PURCHASE'),
217
+ )
218
+ expect(mockWebViewInstance?.injectJavaScript).toHaveBeenCalledWith(
219
+ expect.stringContaining('2999'),
220
+ )
221
+ })
222
+
223
+ it('should inject blockTrackingForMe command', () => {
224
+ const ref = createRef<FathomWebViewRef>()
225
+
226
+ render(<FathomWebView ref={ref} siteId="TEST_SITE" />)
227
+
228
+ ref.current?.blockTrackingForMe()
229
+
230
+ expect(mockWebViewInstance?.injectJavaScript).toHaveBeenCalledWith(
231
+ expect.stringContaining('blockTrackingForMe'),
232
+ )
233
+ })
234
+
235
+ it('should inject enableTrackingForMe command', () => {
236
+ const ref = createRef<FathomWebViewRef>()
237
+
238
+ render(<FathomWebView ref={ref} siteId="TEST_SITE" />)
239
+
240
+ ref.current?.enableTrackingForMe()
241
+
242
+ expect(mockWebViewInstance?.injectJavaScript).toHaveBeenCalledWith(
243
+ expect.stringContaining('enableTrackingForMe'),
244
+ )
245
+ })
246
+ })
247
+
248
+ describe('message handling', () => {
249
+ it('should call onReady when ready message is received', () => {
250
+ const onReady = vi.fn()
251
+ const ref = createRef<FathomWebViewRef>()
252
+
253
+ render(<FathomWebView ref={ref} siteId="TEST_SITE" onReady={onReady} />)
254
+
255
+ // Simulate ready message from WebView (wrapped in act for state update)
256
+ act(() => {
257
+ mockWebViewInstance?.onMessage?.({
258
+ nativeEvent: { data: JSON.stringify({ type: 'ready' }) },
259
+ })
260
+ })
261
+
262
+ expect(onReady).toHaveBeenCalled()
263
+ })
264
+
265
+ it('should set isReady to true when ready message is received', () => {
266
+ const ref = createRef<FathomWebViewRef>()
267
+
268
+ render(<FathomWebView ref={ref} siteId="TEST_SITE" />)
269
+
270
+ expect(ref.current?.isReady()).toBe(false)
271
+
272
+ // Simulate ready message from WebView (wrapped in act for state update)
273
+ act(() => {
274
+ mockWebViewInstance?.onMessage?.({
275
+ nativeEvent: { data: JSON.stringify({ type: 'ready' }) },
276
+ })
277
+ })
278
+
279
+ expect(ref.current?.isReady()).toBe(true)
280
+ })
281
+
282
+ it('should call onError when error message is received', () => {
283
+ const onError = vi.fn()
284
+
285
+ render(<FathomWebView siteId="TEST_SITE" onError={onError} />)
286
+
287
+ // Simulate error message from WebView
288
+ mockWebViewInstance?.onMessage?.({
289
+ nativeEvent: {
290
+ data: JSON.stringify({ type: 'error', message: 'Script failed to load' }),
291
+ },
292
+ })
293
+
294
+ expect(onError).toHaveBeenCalledWith('Script failed to load')
295
+ })
296
+
297
+ it('should handle malformed messages gracefully', () => {
298
+ const onError = vi.fn()
299
+
300
+ render(<FathomWebView siteId="TEST_SITE" onError={onError} />)
301
+
302
+ // Simulate malformed message
303
+ expect(() => {
304
+ mockWebViewInstance?.onMessage?.({
305
+ nativeEvent: { data: 'not valid json' },
306
+ })
307
+ }).not.toThrow()
308
+ })
309
+ })
310
+
311
+ describe('WebView error handling', () => {
312
+ it('should call onError when WebView has an error', () => {
313
+ const onError = vi.fn()
314
+
315
+ render(<FathomWebView siteId="TEST_SITE" onError={onError} />)
316
+
317
+ // Simulate WebView error
318
+ mockWebViewInstance?.onError?.({
319
+ nativeEvent: { description: 'Network error' },
320
+ })
321
+
322
+ expect(onError).toHaveBeenCalledWith('Network error')
323
+ })
324
+
325
+ it('should use default error message when description is missing', () => {
326
+ const onError = vi.fn()
327
+
328
+ render(<FathomWebView siteId="TEST_SITE" onError={onError} />)
329
+
330
+ // Simulate WebView error without description
331
+ mockWebViewInstance?.onError?.({
332
+ nativeEvent: {},
333
+ })
334
+
335
+ expect(onError).toHaveBeenCalledWith('WebView error')
336
+ })
337
+ })
338
+
339
+ describe('debug logging', () => {
340
+ it('should log when debug is enabled', () => {
341
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
342
+ const ref = createRef<FathomWebViewRef>()
343
+
344
+ render(<FathomWebView ref={ref} siteId="TEST_SITE" debug={true} />)
345
+
346
+ ref.current?.trackPageview({ url: '/test' })
347
+
348
+ expect(consoleSpy).toHaveBeenCalled()
349
+ consoleSpy.mockRestore()
350
+ })
351
+
352
+ it('should not log when debug is disabled', () => {
353
+ const consoleSpy = vi.spyOn(console, 'log').mockImplementation(() => {})
354
+ const ref = createRef<FathomWebViewRef>()
355
+
356
+ render(<FathomWebView ref={ref} siteId="TEST_SITE" debug={false} />)
357
+
358
+ ref.current?.trackPageview({ url: '/test' })
359
+
360
+ expect(consoleSpy).not.toHaveBeenCalled()
361
+ consoleSpy.mockRestore()
362
+ })
363
+ })
364
+
365
+ describe('loadOptions', () => {
366
+ it('should include data-honor-dnt attribute when honorDNT is set', () => {
367
+ const { getByTestId } = render(
368
+ <FathomWebView siteId="TEST_SITE" loadOptions={{ honorDNT: true }} />,
369
+ )
370
+
371
+ const webview = getByTestId('webview')
372
+ const source = JSON.parse(webview.getAttribute('data-source') || '{}')
373
+
374
+ expect(source.html).toContain('data-honor-dnt="true"')
375
+ })
376
+
377
+ it('should include data-auto attribute when auto is false', () => {
378
+ const { getByTestId } = render(
379
+ <FathomWebView siteId="TEST_SITE" loadOptions={{ auto: false }} />,
380
+ )
381
+
382
+ const webview = getByTestId('webview')
383
+ const source = JSON.parse(webview.getAttribute('data-source') || '{}')
384
+
385
+ expect(source.html).toContain('data-auto="false"')
386
+ })
387
+
388
+ it('should include data-canonical attribute when canonical is false', () => {
389
+ const { getByTestId } = render(
390
+ <FathomWebView siteId="TEST_SITE" loadOptions={{ canonical: false }} />,
391
+ )
392
+
393
+ const webview = getByTestId('webview')
394
+ const source = JSON.parse(webview.getAttribute('data-source') || '{}')
395
+
396
+ expect(source.html).toContain('data-canonical="false"')
397
+ })
398
+
399
+ it('should include data-spa attribute when spa mode is set', () => {
400
+ const { getByTestId } = render(
401
+ <FathomWebView siteId="TEST_SITE" loadOptions={{ spa: 'auto' }} />,
402
+ )
403
+
404
+ const webview = getByTestId('webview')
405
+ const source = JSON.parse(webview.getAttribute('data-source') || '{}')
406
+
407
+ expect(source.html).toContain('data-spa="auto"')
408
+ })
409
+ })
410
+ })
@@ -0,0 +1,297 @@
1
+ import React, {
2
+ forwardRef,
3
+ useImperativeHandle,
4
+ useRef,
5
+ useState,
6
+ useCallback,
7
+ } from 'react'
8
+ import { StyleSheet, View } from 'react-native'
9
+ import { WebView, type WebViewMessageEvent } from 'react-native-webview'
10
+
11
+ import type { EventOptions, PageViewOptions, LoadOptions } from '../types'
12
+
13
+ export interface FathomWebViewRef {
14
+ trackPageview: (opts?: PageViewOptions) => void
15
+ trackEvent: (eventName: string, opts?: EventOptions) => void
16
+ trackGoal: (code: string, cents: number) => void
17
+ blockTrackingForMe: () => void
18
+ enableTrackingForMe: () => void
19
+ isReady: () => boolean
20
+ }
21
+
22
+ export interface FathomWebViewProps {
23
+ /**
24
+ * Your Fathom Analytics site ID
25
+ */
26
+ siteId: string
27
+
28
+ /**
29
+ * Options passed to fathom.load()
30
+ */
31
+ loadOptions?: LoadOptions
32
+
33
+ /**
34
+ * Custom domain for Fathom script (if using custom domains feature)
35
+ * @default 'cdn.usefathom.com'
36
+ */
37
+ scriptDomain?: string
38
+
39
+ /**
40
+ * Called when the Fathom script has loaded and is ready
41
+ */
42
+ onReady?: () => void
43
+
44
+ /**
45
+ * Called when an error occurs loading the script
46
+ */
47
+ onError?: (error: string) => void
48
+
49
+ /**
50
+ * Enable debug logging
51
+ */
52
+ debug?: boolean
53
+ }
54
+
55
+ /**
56
+ * Hidden WebView component that loads and manages the Fathom Analytics script.
57
+ *
58
+ * This component renders an invisible WebView that loads the official Fathom
59
+ * tracking script, allowing React Native apps to use Fathom's full functionality.
60
+ *
61
+ * @example
62
+ * ```tsx
63
+ * const fathomRef = useRef<FathomWebViewRef>(null)
64
+ *
65
+ * <FathomWebView
66
+ * ref={fathomRef}
67
+ * siteId="ABCDEFGH"
68
+ * onReady={() => console.log('Fathom ready!')}
69
+ * />
70
+ *
71
+ * // Later, track events:
72
+ * fathomRef.current?.trackPageview({ url: '/home' })
73
+ * ```
74
+ */
75
+ export const FathomWebView = forwardRef<FathomWebViewRef, FathomWebViewProps>(
76
+ function FathomWebView(
77
+ {
78
+ siteId,
79
+ loadOptions = {},
80
+ scriptDomain = 'cdn.usefathom.com',
81
+ onReady,
82
+ onError,
83
+ debug = false,
84
+ },
85
+ ref,
86
+ ) {
87
+ const webViewRef = useRef<WebView>(null)
88
+ const [isReady, setIsReady] = useState(false)
89
+
90
+ const log = useCallback(
91
+ (...args: unknown[]) => {
92
+ if (debug) {
93
+ console.log('[react-fathom/webview]', ...args)
94
+ }
95
+ },
96
+ [debug],
97
+ )
98
+
99
+ // Build data attributes for load options
100
+ const buildDataAttributes = useCallback(() => {
101
+ const attrs: string[] = [`data-site="${siteId}"`]
102
+
103
+ if (loadOptions.auto === false) {
104
+ attrs.push('data-auto="false"')
105
+ }
106
+ if (loadOptions.honorDNT) {
107
+ attrs.push('data-honor-dnt="true"')
108
+ }
109
+ if (loadOptions.canonical === false) {
110
+ attrs.push('data-canonical="false"')
111
+ }
112
+ if (loadOptions.spa) {
113
+ attrs.push(`data-spa="${loadOptions.spa}"`)
114
+ }
115
+
116
+ return attrs.join(' ')
117
+ }, [siteId, loadOptions])
118
+
119
+ // HTML content that loads the Fathom script
120
+ const htmlContent = `
121
+ <!DOCTYPE html>
122
+ <html>
123
+ <head>
124
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
125
+ <script src="https://${scriptDomain}/script.js" ${buildDataAttributes()} defer></script>
126
+ <script>
127
+ // Wait for Fathom to be available
128
+ function waitForFathom(callback, maxAttempts = 50) {
129
+ let attempts = 0;
130
+ const check = () => {
131
+ attempts++;
132
+ if (typeof window.fathom !== 'undefined') {
133
+ callback();
134
+ } else if (attempts < maxAttempts) {
135
+ setTimeout(check, 100);
136
+ } else {
137
+ window.ReactNativeWebView.postMessage(JSON.stringify({
138
+ type: 'error',
139
+ message: 'Fathom script failed to load after ' + (maxAttempts * 100) + 'ms'
140
+ }));
141
+ }
142
+ };
143
+ check();
144
+ }
145
+
146
+ // Initialize when DOM is ready
147
+ document.addEventListener('DOMContentLoaded', () => {
148
+ waitForFathom(() => {
149
+ window.ReactNativeWebView.postMessage(JSON.stringify({
150
+ type: 'ready'
151
+ }));
152
+ });
153
+ });
154
+
155
+ // Handle commands from React Native
156
+ window.handleCommand = function(command) {
157
+ if (typeof window.fathom === 'undefined') {
158
+ console.warn('Fathom not loaded yet');
159
+ return;
160
+ }
161
+
162
+ try {
163
+ switch (command.action) {
164
+ case 'trackPageview':
165
+ window.fathom.trackPageview(command.options || {});
166
+ break;
167
+ case 'trackEvent':
168
+ window.fathom.trackEvent(command.eventName, command.options || {});
169
+ break;
170
+ case 'trackGoal':
171
+ window.fathom.trackGoal(command.code, command.cents);
172
+ break;
173
+ case 'blockTrackingForMe':
174
+ window.fathom.blockTrackingForMe();
175
+ break;
176
+ case 'enableTrackingForMe':
177
+ window.fathom.enableTrackingForMe();
178
+ break;
179
+ default:
180
+ console.warn('Unknown command:', command.action);
181
+ }
182
+ } catch (error) {
183
+ window.ReactNativeWebView.postMessage(JSON.stringify({
184
+ type: 'error',
185
+ message: error.message
186
+ }));
187
+ }
188
+ };
189
+ </script>
190
+ </head>
191
+ <body></body>
192
+ </html>
193
+ `
194
+
195
+ const injectCommand = useCallback(
196
+ (command: Record<string, unknown>) => {
197
+ if (!webViewRef.current) {
198
+ log('WebView not available')
199
+ return
200
+ }
201
+
202
+ const script = `window.handleCommand(${JSON.stringify(command)}); true;`
203
+ webViewRef.current.injectJavaScript(script)
204
+ log('Injected command:', command)
205
+ },
206
+ [log],
207
+ )
208
+
209
+ const handleMessage = useCallback(
210
+ (event: WebViewMessageEvent) => {
211
+ try {
212
+ const data = JSON.parse(event.nativeEvent.data)
213
+
214
+ switch (data.type) {
215
+ case 'ready':
216
+ log('Fathom script loaded and ready')
217
+ setIsReady(true)
218
+ onReady?.()
219
+ break
220
+ case 'error':
221
+ log('Error from WebView:', data.message)
222
+ onError?.(data.message)
223
+ break
224
+ default:
225
+ log('Unknown message type:', data.type)
226
+ }
227
+ } catch {
228
+ log('Failed to parse WebView message:', event.nativeEvent.data)
229
+ }
230
+ },
231
+ [log, onReady, onError],
232
+ )
233
+
234
+ // Expose methods via ref
235
+ useImperativeHandle(
236
+ ref,
237
+ () => ({
238
+ trackPageview: (opts?: PageViewOptions) => {
239
+ injectCommand({ action: 'trackPageview', options: opts })
240
+ },
241
+ trackEvent: (eventName: string, opts?: EventOptions) => {
242
+ injectCommand({ action: 'trackEvent', eventName, options: opts })
243
+ },
244
+ trackGoal: (code: string, cents: number) => {
245
+ injectCommand({ action: 'trackGoal', code, cents })
246
+ },
247
+ blockTrackingForMe: () => {
248
+ injectCommand({ action: 'blockTrackingForMe' })
249
+ },
250
+ enableTrackingForMe: () => {
251
+ injectCommand({ action: 'enableTrackingForMe' })
252
+ },
253
+ isReady: () => isReady,
254
+ }),
255
+ [injectCommand, isReady],
256
+ )
257
+
258
+ return (
259
+ <View style={styles.container}>
260
+ <WebView
261
+ ref={webViewRef}
262
+ source={{ html: htmlContent }}
263
+ onMessage={handleMessage}
264
+ onError={(syntheticEvent) => {
265
+ const { nativeEvent } = syntheticEvent
266
+ log('WebView error:', nativeEvent)
267
+ onError?.(nativeEvent.description || 'WebView error')
268
+ }}
269
+ javaScriptEnabled={true}
270
+ domStorageEnabled={true}
271
+ // Hide the WebView completely
272
+ style={styles.webview}
273
+ // Prevent any user interaction
274
+ scrollEnabled={false}
275
+ bounces={false}
276
+ // Optimize for background operation
277
+ cacheEnabled={true}
278
+ incognito={false}
279
+ />
280
+ </View>
281
+ )
282
+ },
283
+ )
284
+
285
+ const styles = StyleSheet.create({
286
+ container: {
287
+ position: 'absolute',
288
+ width: 0,
289
+ height: 0,
290
+ overflow: 'hidden',
291
+ },
292
+ webview: {
293
+ width: 1,
294
+ height: 1,
295
+ opacity: 0,
296
+ },
297
+ })