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.
- package/README.md +941 -25
- package/dist/cjs/index.cjs +55 -9
- package/dist/cjs/index.cjs.map +1 -1
- package/dist/cjs/native/index.cjs +1079 -0
- package/dist/cjs/native/index.cjs.map +1 -0
- package/dist/cjs/next/index.cjs +89 -5
- package/dist/cjs/next/index.cjs.map +1 -1
- package/dist/es/index.js +55 -9
- package/dist/es/index.js.map +1 -1
- package/dist/es/native/index.js +1071 -0
- package/dist/es/native/index.js.map +1 -0
- package/dist/es/next/index.js +90 -6
- package/dist/es/next/index.js.map +1 -1
- package/dist/react-fathom.js +55 -9
- package/dist/react-fathom.js.map +1 -1
- package/dist/react-fathom.min.js +2 -2
- package/dist/react-fathom.min.js.map +1 -1
- package/package.json +28 -5
- package/src/FathomContext.tsx +30 -1
- package/src/FathomProvider.test.tsx +115 -15
- package/src/FathomProvider.tsx +10 -2
- package/src/components/TrackClick.test.tsx +7 -7
- package/src/components/TrackClick.tsx +1 -1
- package/src/components/TrackVisible.test.tsx +7 -7
- package/src/components/TrackVisible.tsx +1 -1
- package/src/hooks/useFathom.test.tsx +14 -3
- package/src/hooks/useTrackOnClick.test.tsx +4 -4
- package/src/hooks/useTrackOnClick.ts +1 -1
- package/src/hooks/useTrackOnVisible.test.tsx +4 -4
- package/src/hooks/useTrackOnVisible.ts +1 -1
- package/src/index.ts +1 -0
- package/src/native/FathomWebView.test.tsx +410 -0
- package/src/native/FathomWebView.tsx +297 -0
- package/src/native/NativeFathomProvider.test.tsx +372 -0
- package/src/native/NativeFathomProvider.tsx +113 -0
- package/src/native/createWebViewClient.test.ts +380 -0
- package/src/native/createWebViewClient.ts +271 -0
- package/src/native/index.ts +29 -0
- package/src/native/react-native.d.ts +74 -0
- package/src/native/types.ts +145 -0
- package/src/native/useAppStateTracking.test.ts +249 -0
- package/src/native/useAppStateTracking.ts +66 -0
- package/src/native/useNavigationTracking.test.ts +446 -0
- package/src/native/useNavigationTracking.ts +177 -0
- package/src/next/NextFathomProviderApp.client.tsx +5 -0
- package/src/next/NextFathomProviderApp.test.tsx +154 -0
- package/src/next/NextFathomProviderApp.tsx +62 -0
- package/src/next/index.ts +3 -0
- package/src/types.ts +36 -9
- package/types/FathomContext.d.ts +1 -1
- package/types/FathomContext.d.ts.map +1 -1
- package/types/FathomProvider.d.ts.map +1 -1
- package/types/components/TrackClick.d.ts +1 -1
- package/types/components/TrackVisible.d.ts +1 -1
- package/types/hooks/useTrackOnClick.d.ts +1 -1
- package/types/hooks/useTrackOnVisible.d.ts +1 -1
- package/types/index.d.ts +1 -0
- package/types/index.d.ts.map +1 -1
- package/types/native/FathomWebView.d.ts +59 -0
- package/types/native/FathomWebView.d.ts.map +1 -0
- package/types/native/NativeFathomProvider.d.ts +36 -0
- package/types/native/NativeFathomProvider.d.ts.map +1 -0
- package/types/native/createWebViewClient.d.ts +51 -0
- package/types/native/createWebViewClient.d.ts.map +1 -0
- package/types/native/index.d.ts +10 -0
- package/types/native/index.d.ts.map +1 -0
- package/types/native/types.d.ts +125 -0
- package/types/native/types.d.ts.map +1 -0
- package/types/native/useAppStateTracking.d.ts +25 -0
- package/types/native/useAppStateTracking.d.ts.map +1 -0
- package/types/native/useNavigationTracking.d.ts +30 -0
- package/types/native/useNavigationTracking.d.ts.map +1 -0
- package/types/next/NextFathomProviderApp.client.d.ts +3 -0
- package/types/next/NextFathomProviderApp.client.d.ts.map +1 -0
- package/types/next/NextFathomProviderApp.d.ts +38 -4
- package/types/next/NextFathomProviderApp.d.ts.map +1 -1
- package/types/next/index.d.ts +1 -0
- package/types/next/index.d.ts.map +1 -1
- package/types/types.d.ts +34 -9
- 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
|
+
})
|