• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

ryanhefner / react-fathom / e61b5ace-542a-4708-b4ff-2e866ef7d8e6

07 Jan 2026 08:52PM UTC coverage: 93.197% (-3.1%) from 96.266%
e61b5ace-542a-4708-b4ff-2e866ef7d8e6

push

circleci

web-flow
Merge pull request #3 from ryanhefner/claude/improve-react-fathom-docs-Cve3K

[feat] React Native Client - Part 1

175 of 193 branches covered (90.67%)

Branch coverage included in aggregate %.

224 of 243 new or added lines in 7 files covered. (92.18%)

373 of 395 relevant lines covered (94.43%)

16.48 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

92.96
/src/native/FathomWebView.tsx
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>(
1✔
76
  function FathomWebView(
77
    {
78
      siteId,
79
      loadOptions = {},
31✔
80
      scriptDomain = 'cdn.usefathom.com',
31✔
81
      onReady,
82
      onError,
83
      debug = false,
31✔
84
    },
85
    ref,
86
  ) {
87
    const webViewRef = useRef<WebView>(null)
31✔
88
    const [isReady, setIsReady] = useState(false)
31✔
89

90
    const log = useCallback(
31✔
91
      (...args: unknown[]) => {
92
        if (debug) {
13✔
93
          console.log('[react-fathom/webview]', ...args)
1✔
94
        }
95
      },
96
      [debug],
97
    )
98

99
    // Build data attributes for load options
100
    const buildDataAttributes = useCallback(() => {
31✔
101
      const attrs: string[] = [`data-site="${siteId}"`]
31✔
102

103
      if (loadOptions.auto === false) {
31✔
104
        attrs.push('data-auto="false"')
1✔
105
      }
106
      if (loadOptions.honorDNT) {
31✔
107
        attrs.push('data-honor-dnt="true"')
1✔
108
      }
109
      if (loadOptions.canonical === false) {
31✔
110
        attrs.push('data-canonical="false"')
1✔
111
      }
112
      if (loadOptions.spa) {
31✔
113
        attrs.push(`data-spa="${loadOptions.spa}"`)
1✔
114
      }
115

116
      return attrs.join(' ')
31✔
117
    }, [siteId, loadOptions])
118

119
    // HTML content that loads the Fathom script
120
    const htmlContent = `
31✔
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(
31✔
196
      (command: Record<string, unknown>) => {
197
        if (!webViewRef.current) {
7!
NEW
198
          log('WebView not available')
×
NEW
199
          return
×
200
        }
201

202
        const script = `window.handleCommand(${JSON.stringify(command)}); true;`
7✔
203
        webViewRef.current.injectJavaScript(script)
7✔
204
        log('Injected command:', command)
7✔
205
      },
206
      [log],
207
    )
208

209
    const handleMessage = useCallback(
31✔
210
      (event: WebViewMessageEvent) => {
211
        try {
4✔
212
          const data = JSON.parse(event.nativeEvent.data)
4✔
213

214
          switch (data.type) {
4!
215
            case 'ready':
216
              log('Fathom script loaded and ready')
2✔
217
              setIsReady(true)
2✔
218
              onReady?.()
2✔
219
              break
2✔
220
            case 'error':
221
              log('Error from WebView:', data.message)
1✔
222
              onError?.(data.message)
1✔
223
              break
1✔
224
            default:
NEW
225
              log('Unknown message type:', data.type)
×
226
          }
227
        } catch {
228
          log('Failed to parse WebView message:', event.nativeEvent.data)
1✔
229
        }
230
      },
231
      [log, onReady, onError],
232
    )
233

234
    // Expose methods via ref
235
    useImperativeHandle(
31✔
236
      ref,
237
      () => ({
18✔
238
        trackPageview: (opts?: PageViewOptions) => {
239
          injectCommand({ action: 'trackPageview', options: opts })
3✔
240
        },
241
        trackEvent: (eventName: string, opts?: EventOptions) => {
242
          injectCommand({ action: 'trackEvent', eventName, options: opts })
1✔
243
        },
244
        trackGoal: (code: string, cents: number) => {
245
          injectCommand({ action: 'trackGoal', code, cents })
1✔
246
        },
247
        blockTrackingForMe: () => {
248
          injectCommand({ action: 'blockTrackingForMe' })
1✔
249
        },
250
        enableTrackingForMe: () => {
251
          injectCommand({ action: 'enableTrackingForMe' })
1✔
252
        },
253
        isReady: () => isReady,
3✔
254
      }),
255
      [injectCommand, isReady],
256
    )
257

258
    return (
31✔
259
      <View style={styles.container}>
260
        <WebView
261
          ref={webViewRef}
262
          source={{ html: htmlContent }}
263
          onMessage={handleMessage}
264
          onError={(syntheticEvent) => {
265
            const { nativeEvent } = syntheticEvent
2✔
266
            log('WebView error:', nativeEvent)
2✔
267
            onError?.(nativeEvent.description || 'WebView error')
2✔
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({
1✔
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
})
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc