react-fathom 0.1.11 → 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 (70) hide show
  1. package/README.md +886 -24
  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 +51 -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 +51 -5
  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 +27 -4
  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/types.ts +36 -9
  46. package/types/FathomContext.d.ts +1 -1
  47. package/types/FathomContext.d.ts.map +1 -1
  48. package/types/FathomProvider.d.ts.map +1 -1
  49. package/types/components/TrackClick.d.ts +1 -1
  50. package/types/components/TrackVisible.d.ts +1 -1
  51. package/types/hooks/useTrackOnClick.d.ts +1 -1
  52. package/types/hooks/useTrackOnVisible.d.ts +1 -1
  53. package/types/index.d.ts +1 -0
  54. package/types/index.d.ts.map +1 -1
  55. package/types/native/FathomWebView.d.ts +59 -0
  56. package/types/native/FathomWebView.d.ts.map +1 -0
  57. package/types/native/NativeFathomProvider.d.ts +36 -0
  58. package/types/native/NativeFathomProvider.d.ts.map +1 -0
  59. package/types/native/createWebViewClient.d.ts +51 -0
  60. package/types/native/createWebViewClient.d.ts.map +1 -0
  61. package/types/native/index.d.ts +10 -0
  62. package/types/native/index.d.ts.map +1 -0
  63. package/types/native/types.d.ts +125 -0
  64. package/types/native/types.d.ts.map +1 -0
  65. package/types/native/useAppStateTracking.d.ts +25 -0
  66. package/types/native/useAppStateTracking.d.ts.map +1 -0
  67. package/types/native/useNavigationTracking.d.ts +30 -0
  68. package/types/native/useNavigationTracking.d.ts.map +1 -0
  69. package/types/types.d.ts +34 -9
  70. package/types/types.d.ts.map +1 -1
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Minimal type declarations for react-native modules used by react-fathom/native.
3
+ * This allows the package to compile without requiring react-native as a dev dependency.
4
+ */
5
+ declare module 'react-native' {
6
+ export type AppStateStatus = 'active' | 'background' | 'inactive'
7
+
8
+ export interface AppStateStatic {
9
+ currentState: AppStateStatus
10
+ addEventListener(
11
+ event: 'change',
12
+ handler: (state: AppStateStatus) => void,
13
+ ): { remove: () => void }
14
+ }
15
+
16
+ export const AppState: AppStateStatic
17
+
18
+ export interface ViewStyle {
19
+ position?: 'absolute' | 'relative'
20
+ width?: number | string
21
+ height?: number | string
22
+ overflow?: 'visible' | 'hidden' | 'scroll'
23
+ opacity?: number
24
+ }
25
+
26
+ export interface StyleSheetStatic {
27
+ create<T extends Record<string, ViewStyle>>(styles: T): T
28
+ }
29
+
30
+ export const StyleSheet: StyleSheetStatic
31
+
32
+ export interface ViewProps {
33
+ style?: ViewStyle
34
+ children?: React.ReactNode
35
+ }
36
+
37
+ export const View: React.FC<ViewProps>
38
+ }
39
+
40
+ declare module 'react-native-webview' {
41
+ import type { Ref, RefObject, Component } from 'react'
42
+
43
+ export interface WebViewMessageEvent {
44
+ nativeEvent: {
45
+ data: string
46
+ }
47
+ }
48
+
49
+ export interface WebViewErrorEvent {
50
+ nativeEvent: {
51
+ description?: string
52
+ code?: number
53
+ domain?: string
54
+ url?: string
55
+ }
56
+ }
57
+
58
+ export interface WebViewProps {
59
+ source?: { html: string; uri?: never } | { uri: string; html?: never }
60
+ onMessage?: (event: WebViewMessageEvent) => void
61
+ onError?: (event: WebViewErrorEvent) => void
62
+ javaScriptEnabled?: boolean
63
+ domStorageEnabled?: boolean
64
+ style?: Record<string, unknown>
65
+ scrollEnabled?: boolean
66
+ bounces?: boolean
67
+ cacheEnabled?: boolean
68
+ incognito?: boolean
69
+ }
70
+
71
+ export class WebView extends Component<WebViewProps> {
72
+ injectJavaScript(script: string): void
73
+ }
74
+ }
@@ -0,0 +1,145 @@
1
+ import type { MutableRefObject } from 'react'
2
+ import type { FathomClient, EventOptions, LoadOptions, PageViewOptions } from '../types'
3
+ import type { WebViewFathomClient } from './createWebViewClient'
4
+
5
+ /**
6
+ * Options for the NativeFathomProvider component
7
+ */
8
+ export interface NativeFathomProviderProps {
9
+ /**
10
+ * Your Fathom Analytics site ID
11
+ */
12
+ siteId: string
13
+
14
+ /**
15
+ * Options passed to fathom.load() in the WebView
16
+ */
17
+ loadOptions?: LoadOptions
18
+
19
+ /**
20
+ * Custom domain for Fathom script (if using Fathom's custom domains feature)
21
+ * @default 'cdn.usefathom.com'
22
+ */
23
+ scriptDomain?: string
24
+
25
+ /**
26
+ * Default options merged into all trackPageview calls
27
+ */
28
+ defaultPageviewOptions?: PageViewOptions
29
+
30
+ /**
31
+ * Default options merged into all trackEvent calls
32
+ */
33
+ defaultEventOptions?: EventOptions
34
+
35
+ /**
36
+ * Enable automatic app state tracking (foreground/background)
37
+ * Tracks 'app-foreground' and 'app-background' events
38
+ * @default false
39
+ */
40
+ trackAppState?: boolean
41
+
42
+ /**
43
+ * Enable debug logging
44
+ * @default false
45
+ */
46
+ debug?: boolean
47
+
48
+ /**
49
+ * Called when the Fathom script has loaded and is ready
50
+ */
51
+ onReady?: () => void
52
+
53
+ /**
54
+ * Called when an error occurs loading the script
55
+ */
56
+ onError?: (error: string) => void
57
+
58
+ /**
59
+ * A ref that will be populated with the WebView-based Fathom client instance.
60
+ * This allows the parent component that composes the provider to access
61
+ * the client directly, since it cannot use useFathom() (context only flows
62
+ * downward to children).
63
+ *
64
+ * The client is a WebViewFathomClient which extends FathomClient with
65
+ * additional methods for queue management.
66
+ *
67
+ * @example
68
+ * ```tsx
69
+ * import { NativeFathomProvider, WebViewFathomClient } from 'react-fathom/native'
70
+ *
71
+ * function App() {
72
+ * const clientRef = useRef<WebViewFathomClient>(null);
73
+ *
74
+ * const handleDeepLink = (url: string) => {
75
+ * clientRef.current?.trackEvent('deep_link', { _url: url });
76
+ * };
77
+ *
78
+ * return (
79
+ * <NativeFathomProvider siteId="..." clientRef={clientRef}>
80
+ * <YourApp />
81
+ * </NativeFathomProvider>
82
+ * );
83
+ * }
84
+ * ```
85
+ */
86
+ clientRef?: MutableRefObject<WebViewFathomClient | null>
87
+
88
+ /**
89
+ * Children to render
90
+ */
91
+ children: React.ReactNode
92
+ }
93
+
94
+ /**
95
+ * Options for the useNavigationTracking hook
96
+ */
97
+ export interface UseNavigationTrackingOptions {
98
+ /**
99
+ * React Navigation navigation container ref
100
+ */
101
+ navigationRef: React.RefObject<any>
102
+
103
+ /**
104
+ * Transform the route name before tracking (e.g., add prefixes)
105
+ */
106
+ transformRouteName?: (routeName: string) => string
107
+
108
+ /**
109
+ * Filter which routes should be tracked (return false to skip)
110
+ */
111
+ shouldTrackRoute?: (routeName: string, params?: Record<string, any>) => boolean
112
+
113
+ /**
114
+ * Include route params in the tracked URL
115
+ */
116
+ includeParams?: boolean
117
+ }
118
+
119
+ /**
120
+ * Options for the useAppStateTracking hook
121
+ */
122
+ export interface UseAppStateTrackingOptions {
123
+ /**
124
+ * Event name for when the app comes to foreground (default: 'app-foreground')
125
+ */
126
+ foregroundEventName?: string
127
+
128
+ /**
129
+ * Event name for when the app goes to background (default: 'app-background')
130
+ */
131
+ backgroundEventName?: string
132
+
133
+ /**
134
+ * Additional event options to include with app state events
135
+ */
136
+ eventOptions?: EventOptions
137
+
138
+ /**
139
+ * Callback when app state changes
140
+ */
141
+ onStateChange?: (state: 'active' | 'background' | 'inactive') => void
142
+ }
143
+
144
+ // Re-export core types for convenience
145
+ export type { FathomClient, EventOptions, LoadOptions, PageViewOptions }
@@ -0,0 +1,249 @@
1
+ import React from 'react'
2
+
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ import { renderHook, act } from '@testing-library/react'
6
+
7
+ import { FathomProvider } from '../FathomProvider'
8
+ import { useAppStateTracking } from './useAppStateTracking'
9
+
10
+ // Store the listener callback for testing
11
+ let appStateChangeCallback: ((state: string) => void) | null = null
12
+ const mockRemove = vi.fn()
13
+
14
+ // Mock react-native AppState
15
+ vi.mock('react-native', () => ({
16
+ AppState: {
17
+ currentState: 'active',
18
+ addEventListener: vi.fn((event, callback) => {
19
+ if (event === 'change') {
20
+ appStateChangeCallback = callback
21
+ }
22
+ return { remove: mockRemove }
23
+ }),
24
+ },
25
+ }))
26
+
27
+ describe('useAppStateTracking', () => {
28
+ const mockTrackEvent = vi.fn()
29
+ const mockClient = {
30
+ trackEvent: mockTrackEvent,
31
+ trackPageview: vi.fn(),
32
+ trackGoal: vi.fn(),
33
+ load: vi.fn(),
34
+ setSite: vi.fn(),
35
+ blockTrackingForMe: vi.fn(),
36
+ enableTrackingForMe: vi.fn(),
37
+ isTrackingEnabled: vi.fn(() => true),
38
+ }
39
+
40
+ beforeEach(() => {
41
+ vi.clearAllMocks()
42
+ appStateChangeCallback = null
43
+ })
44
+
45
+ const createWrapper = () => {
46
+ return ({ children }: { children: React.ReactNode }) =>
47
+ React.createElement(FathomProvider, { client: mockClient }, children)
48
+ }
49
+
50
+ it('should set up AppState listener on mount', async () => {
51
+ const reactNative = await import('react-native')
52
+
53
+ renderHook(() => useAppStateTracking(), { wrapper: createWrapper() })
54
+
55
+ expect(reactNative.AppState.addEventListener).toHaveBeenCalledWith(
56
+ 'change',
57
+ expect.any(Function),
58
+ )
59
+ })
60
+
61
+ it('should clean up listener on unmount', () => {
62
+ const { unmount } = renderHook(() => useAppStateTracking(), {
63
+ wrapper: createWrapper(),
64
+ })
65
+
66
+ unmount()
67
+
68
+ expect(mockRemove).toHaveBeenCalled()
69
+ })
70
+
71
+ it('should track foreground event when app becomes active', () => {
72
+ renderHook(() => useAppStateTracking(), { wrapper: createWrapper() })
73
+
74
+ // Simulate app going to background then foreground
75
+ act(() => {
76
+ appStateChangeCallback?.('background')
77
+ })
78
+
79
+ act(() => {
80
+ appStateChangeCallback?.('active')
81
+ })
82
+
83
+ expect(mockTrackEvent).toHaveBeenCalledWith('app-foreground', expect.anything())
84
+ })
85
+
86
+ it('should track background event when app goes to background', () => {
87
+ renderHook(() => useAppStateTracking(), { wrapper: createWrapper() })
88
+
89
+ // Simulate app going to background (from active state)
90
+ act(() => {
91
+ appStateChangeCallback?.('background')
92
+ })
93
+
94
+ expect(mockTrackEvent).toHaveBeenCalledWith('app-background', expect.anything())
95
+ })
96
+
97
+ it('should use custom foreground event name', () => {
98
+ renderHook(
99
+ () =>
100
+ useAppStateTracking({
101
+ foregroundEventName: 'app-resumed',
102
+ }),
103
+ { wrapper: createWrapper() },
104
+ )
105
+
106
+ // Simulate app going to background then foreground
107
+ act(() => {
108
+ appStateChangeCallback?.('background')
109
+ })
110
+
111
+ act(() => {
112
+ appStateChangeCallback?.('active')
113
+ })
114
+
115
+ expect(mockTrackEvent).toHaveBeenCalledWith('app-resumed', expect.anything())
116
+ })
117
+
118
+ it('should use custom background event name', () => {
119
+ renderHook(
120
+ () =>
121
+ useAppStateTracking({
122
+ backgroundEventName: 'app-paused',
123
+ }),
124
+ { wrapper: createWrapper() },
125
+ )
126
+
127
+ act(() => {
128
+ appStateChangeCallback?.('background')
129
+ })
130
+
131
+ expect(mockTrackEvent).toHaveBeenCalledWith('app-paused', expect.anything())
132
+ })
133
+
134
+ it('should include eventOptions in tracked events', () => {
135
+ const eventOptions = { _site_id: 'app-state-tracking' }
136
+
137
+ renderHook(
138
+ () =>
139
+ useAppStateTracking({
140
+ eventOptions,
141
+ }),
142
+ { wrapper: createWrapper() },
143
+ )
144
+
145
+ act(() => {
146
+ appStateChangeCallback?.('background')
147
+ })
148
+
149
+ expect(mockTrackEvent).toHaveBeenCalledWith('app-background', eventOptions)
150
+ })
151
+
152
+ it('should call onStateChange callback when state changes', () => {
153
+ const onStateChange = vi.fn()
154
+
155
+ renderHook(
156
+ () =>
157
+ useAppStateTracking({
158
+ onStateChange,
159
+ }),
160
+ { wrapper: createWrapper() },
161
+ )
162
+
163
+ act(() => {
164
+ appStateChangeCallback?.('background')
165
+ })
166
+
167
+ expect(onStateChange).toHaveBeenCalledWith('background')
168
+ })
169
+
170
+ it('should track inactive state as background', () => {
171
+ renderHook(() => useAppStateTracking(), { wrapper: createWrapper() })
172
+
173
+ act(() => {
174
+ appStateChangeCallback?.('inactive')
175
+ })
176
+
177
+ expect(mockTrackEvent).toHaveBeenCalledWith('app-background', expect.anything())
178
+ })
179
+
180
+ it('should track foreground when transitioning from inactive to active', () => {
181
+ renderHook(() => useAppStateTracking(), { wrapper: createWrapper() })
182
+
183
+ // Go to inactive first
184
+ act(() => {
185
+ appStateChangeCallback?.('inactive')
186
+ })
187
+
188
+ mockTrackEvent.mockClear()
189
+
190
+ // Then to active
191
+ act(() => {
192
+ appStateChangeCallback?.('active')
193
+ })
194
+
195
+ expect(mockTrackEvent).toHaveBeenCalledWith('app-foreground', expect.anything())
196
+ })
197
+
198
+ it('should not track when transitioning between inactive and background', () => {
199
+ renderHook(() => useAppStateTracking(), { wrapper: createWrapper() })
200
+
201
+ // Go to inactive first
202
+ act(() => {
203
+ appStateChangeCallback?.('inactive')
204
+ })
205
+
206
+ mockTrackEvent.mockClear()
207
+
208
+ // Then to background (no foreground event should be tracked)
209
+ act(() => {
210
+ appStateChangeCallback?.('background')
211
+ })
212
+
213
+ // Should not track foreground event
214
+ expect(mockTrackEvent).not.toHaveBeenCalledWith(
215
+ 'app-foreground',
216
+ expect.anything(),
217
+ )
218
+ })
219
+
220
+ it('should work with all options combined', () => {
221
+ const onStateChange = vi.fn()
222
+ const eventOptions = { _value: 1 }
223
+
224
+ renderHook(
225
+ () =>
226
+ useAppStateTracking({
227
+ foregroundEventName: 'resumed',
228
+ backgroundEventName: 'paused',
229
+ eventOptions,
230
+ onStateChange,
231
+ }),
232
+ { wrapper: createWrapper() },
233
+ )
234
+
235
+ act(() => {
236
+ appStateChangeCallback?.('background')
237
+ })
238
+
239
+ expect(mockTrackEvent).toHaveBeenCalledWith('paused', eventOptions)
240
+ expect(onStateChange).toHaveBeenCalledWith('background')
241
+
242
+ act(() => {
243
+ appStateChangeCallback?.('active')
244
+ })
245
+
246
+ expect(mockTrackEvent).toHaveBeenCalledWith('resumed', eventOptions)
247
+ expect(onStateChange).toHaveBeenCalledWith('active')
248
+ })
249
+ })
@@ -0,0 +1,66 @@
1
+ import { useEffect, useRef } from 'react'
2
+ import { AppState, type AppStateStatus } from 'react-native'
3
+
4
+ import { useFathom } from '../hooks/useFathom'
5
+ import type { UseAppStateTrackingOptions } from './types'
6
+
7
+ /**
8
+ * Hook that tracks app state changes (foreground/background) as Fathom events.
9
+ *
10
+ * This is useful for understanding user engagement patterns and session behavior.
11
+ *
12
+ * @example
13
+ * ```tsx
14
+ * import { useAppStateTracking } from 'react-fathom/native'
15
+ *
16
+ * function App() {
17
+ * useAppStateTracking({
18
+ * foregroundEventName: 'app-resumed',
19
+ * backgroundEventName: 'app-paused',
20
+ * onStateChange: (state) => {
21
+ * console.log('App state:', state)
22
+ * },
23
+ * })
24
+ *
25
+ * return <YourApp />
26
+ * }
27
+ * ```
28
+ */
29
+ export function useAppStateTracking(options: UseAppStateTrackingOptions = {}) {
30
+ const {
31
+ foregroundEventName = 'app-foreground',
32
+ backgroundEventName = 'app-background',
33
+ eventOptions,
34
+ onStateChange,
35
+ } = options
36
+
37
+ const { trackEvent } = useFathom()
38
+ const appStateRef = useRef<AppStateStatus>(AppState.currentState)
39
+
40
+ useEffect(() => {
41
+ const handleAppStateChange = (nextAppState: AppStateStatus) => {
42
+ const previousState = appStateRef.current
43
+
44
+ // Track when app comes to foreground
45
+ if (previousState.match(/inactive|background/) && nextAppState === 'active') {
46
+ trackEvent?.(foregroundEventName, eventOptions)
47
+ }
48
+
49
+ // Track when app goes to background
50
+ if (previousState === 'active' && nextAppState.match(/inactive|background/)) {
51
+ trackEvent?.(backgroundEventName, eventOptions)
52
+ }
53
+
54
+ // Call the optional state change callback
55
+ onStateChange?.(nextAppState as 'active' | 'background' | 'inactive')
56
+
57
+ appStateRef.current = nextAppState
58
+ }
59
+
60
+ const subscription = AppState.addEventListener('change', handleAppStateChange)
61
+
62
+ return () => {
63
+ subscription.remove()
64
+ }
65
+ }, [trackEvent, foregroundEventName, backgroundEventName, eventOptions, onStateChange])
66
+ }