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,446 @@
|
|
|
1
|
+
import React from 'react'
|
|
2
|
+
|
|
3
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
|
|
5
|
+
import { renderHook, act, waitFor } from '@testing-library/react'
|
|
6
|
+
|
|
7
|
+
import { FathomProvider } from '../FathomProvider'
|
|
8
|
+
import { useNavigationTracking } from './useNavigationTracking'
|
|
9
|
+
|
|
10
|
+
describe('useNavigationTracking', () => {
|
|
11
|
+
const mockTrackPageview = vi.fn()
|
|
12
|
+
const mockClient = {
|
|
13
|
+
trackEvent: vi.fn(),
|
|
14
|
+
trackPageview: mockTrackPageview,
|
|
15
|
+
trackGoal: vi.fn(),
|
|
16
|
+
load: vi.fn(),
|
|
17
|
+
setSite: vi.fn(),
|
|
18
|
+
blockTrackingForMe: vi.fn(),
|
|
19
|
+
enableTrackingForMe: vi.fn(),
|
|
20
|
+
isTrackingEnabled: vi.fn(() => true),
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Store listener callbacks for testing
|
|
24
|
+
let stateChangeCallback: (() => void) | null = null
|
|
25
|
+
|
|
26
|
+
const createMockNavigationRef = (initialRoute = 'Home', params?: object) => {
|
|
27
|
+
let currentRoute = initialRoute
|
|
28
|
+
let currentParams = params
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
current: {
|
|
32
|
+
getRootState: () => ({
|
|
33
|
+
index: 0,
|
|
34
|
+
routes: [{ name: currentRoute, params: currentParams }],
|
|
35
|
+
}),
|
|
36
|
+
addListener: vi.fn((event, callback) => {
|
|
37
|
+
if (event === 'state') {
|
|
38
|
+
stateChangeCallback = callback
|
|
39
|
+
}
|
|
40
|
+
return vi.fn() // Return unsubscribe function
|
|
41
|
+
}),
|
|
42
|
+
// Helper method for testing to change route
|
|
43
|
+
__setRoute: (name: string, newParams?: object) => {
|
|
44
|
+
currentRoute = name
|
|
45
|
+
currentParams = newParams
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
vi.clearAllMocks()
|
|
53
|
+
stateChangeCallback = null
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
const createWrapper = () => {
|
|
57
|
+
return ({ children }: { children: React.ReactNode }) =>
|
|
58
|
+
React.createElement(FathomProvider, { client: mockClient }, children)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
it('should set up navigation listener on mount', async () => {
|
|
62
|
+
const navigationRef = createMockNavigationRef()
|
|
63
|
+
|
|
64
|
+
renderHook(
|
|
65
|
+
() =>
|
|
66
|
+
useNavigationTracking({
|
|
67
|
+
navigationRef: navigationRef as any,
|
|
68
|
+
}),
|
|
69
|
+
{ wrapper: createWrapper() },
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
await waitFor(() => {
|
|
73
|
+
expect(navigationRef.current.addListener).toHaveBeenCalledWith(
|
|
74
|
+
'state',
|
|
75
|
+
expect.any(Function),
|
|
76
|
+
)
|
|
77
|
+
})
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it('should track initial route on mount', async () => {
|
|
81
|
+
const navigationRef = createMockNavigationRef('Home')
|
|
82
|
+
|
|
83
|
+
renderHook(
|
|
84
|
+
() =>
|
|
85
|
+
useNavigationTracking({
|
|
86
|
+
navigationRef: navigationRef as any,
|
|
87
|
+
}),
|
|
88
|
+
{ wrapper: createWrapper() },
|
|
89
|
+
)
|
|
90
|
+
|
|
91
|
+
await waitFor(() => {
|
|
92
|
+
expect(mockTrackPageview).toHaveBeenCalledWith({
|
|
93
|
+
url: '/Home',
|
|
94
|
+
})
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
it('should track route changes', async () => {
|
|
99
|
+
const navigationRef = createMockNavigationRef('Home')
|
|
100
|
+
|
|
101
|
+
renderHook(
|
|
102
|
+
() =>
|
|
103
|
+
useNavigationTracking({
|
|
104
|
+
navigationRef: navigationRef as any,
|
|
105
|
+
}),
|
|
106
|
+
{ wrapper: createWrapper() },
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
// Wait for initial tracking
|
|
110
|
+
await waitFor(() => {
|
|
111
|
+
expect(mockTrackPageview).toHaveBeenCalled()
|
|
112
|
+
})
|
|
113
|
+
|
|
114
|
+
mockTrackPageview.mockClear()
|
|
115
|
+
|
|
116
|
+
// Simulate navigation to Settings
|
|
117
|
+
navigationRef.current.__setRoute('Settings')
|
|
118
|
+
|
|
119
|
+
act(() => {
|
|
120
|
+
stateChangeCallback?.()
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
await waitFor(() => {
|
|
124
|
+
expect(mockTrackPageview).toHaveBeenCalledWith({
|
|
125
|
+
url: '/Settings',
|
|
126
|
+
referrer: '/Home',
|
|
127
|
+
})
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it('should not track same route twice', async () => {
|
|
132
|
+
const navigationRef = createMockNavigationRef('Home')
|
|
133
|
+
|
|
134
|
+
renderHook(
|
|
135
|
+
() =>
|
|
136
|
+
useNavigationTracking({
|
|
137
|
+
navigationRef: navigationRef as any,
|
|
138
|
+
}),
|
|
139
|
+
{ wrapper: createWrapper() },
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
await waitFor(() => {
|
|
143
|
+
expect(mockTrackPageview).toHaveBeenCalledTimes(1)
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
// Trigger state change without route change
|
|
147
|
+
act(() => {
|
|
148
|
+
stateChangeCallback?.()
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
// Should still only have been called once
|
|
152
|
+
expect(mockTrackPageview).toHaveBeenCalledTimes(1)
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
it('should transform route names when transformRouteName is provided', async () => {
|
|
156
|
+
const navigationRef = createMockNavigationRef('Home')
|
|
157
|
+
|
|
158
|
+
renderHook(
|
|
159
|
+
() =>
|
|
160
|
+
useNavigationTracking({
|
|
161
|
+
navigationRef: navigationRef as any,
|
|
162
|
+
transformRouteName: (name) => `/screens/${name.toLowerCase()}`,
|
|
163
|
+
}),
|
|
164
|
+
{ wrapper: createWrapper() },
|
|
165
|
+
)
|
|
166
|
+
|
|
167
|
+
await waitFor(() => {
|
|
168
|
+
expect(mockTrackPageview).toHaveBeenCalledWith({
|
|
169
|
+
url: '/screens/home',
|
|
170
|
+
})
|
|
171
|
+
})
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it('should filter routes when shouldTrackRoute returns false', async () => {
|
|
175
|
+
const navigationRef = createMockNavigationRef('ModalScreen')
|
|
176
|
+
|
|
177
|
+
renderHook(
|
|
178
|
+
() =>
|
|
179
|
+
useNavigationTracking({
|
|
180
|
+
navigationRef: navigationRef as any,
|
|
181
|
+
shouldTrackRoute: (name) => !name.includes('Modal'),
|
|
182
|
+
}),
|
|
183
|
+
{ wrapper: createWrapper() },
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
// Wait a bit to ensure tracking doesn't happen
|
|
187
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
188
|
+
|
|
189
|
+
expect(mockTrackPageview).not.toHaveBeenCalled()
|
|
190
|
+
})
|
|
191
|
+
|
|
192
|
+
it('should track route when shouldTrackRoute returns true', async () => {
|
|
193
|
+
const navigationRef = createMockNavigationRef('Home')
|
|
194
|
+
|
|
195
|
+
renderHook(
|
|
196
|
+
() =>
|
|
197
|
+
useNavigationTracking({
|
|
198
|
+
navigationRef: navigationRef as any,
|
|
199
|
+
shouldTrackRoute: (name) => !name.includes('Modal'),
|
|
200
|
+
}),
|
|
201
|
+
{ wrapper: createWrapper() },
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
await waitFor(() => {
|
|
205
|
+
expect(mockTrackPageview).toHaveBeenCalledWith({
|
|
206
|
+
url: '/Home',
|
|
207
|
+
})
|
|
208
|
+
})
|
|
209
|
+
})
|
|
210
|
+
|
|
211
|
+
it('should include params when includeParams is true', async () => {
|
|
212
|
+
const navigationRef = createMockNavigationRef('Profile', { userId: '123' })
|
|
213
|
+
|
|
214
|
+
renderHook(
|
|
215
|
+
() =>
|
|
216
|
+
useNavigationTracking({
|
|
217
|
+
navigationRef: navigationRef as any,
|
|
218
|
+
includeParams: true,
|
|
219
|
+
}),
|
|
220
|
+
{ wrapper: createWrapper() },
|
|
221
|
+
)
|
|
222
|
+
|
|
223
|
+
await waitFor(() => {
|
|
224
|
+
expect(mockTrackPageview).toHaveBeenCalledWith({
|
|
225
|
+
url: '/Profile?userId=123',
|
|
226
|
+
})
|
|
227
|
+
})
|
|
228
|
+
})
|
|
229
|
+
|
|
230
|
+
it('should not include params when includeParams is false', async () => {
|
|
231
|
+
const navigationRef = createMockNavigationRef('Profile', { userId: '123' })
|
|
232
|
+
|
|
233
|
+
renderHook(
|
|
234
|
+
() =>
|
|
235
|
+
useNavigationTracking({
|
|
236
|
+
navigationRef: navigationRef as any,
|
|
237
|
+
includeParams: false,
|
|
238
|
+
}),
|
|
239
|
+
{ wrapper: createWrapper() },
|
|
240
|
+
)
|
|
241
|
+
|
|
242
|
+
await waitFor(() => {
|
|
243
|
+
expect(mockTrackPageview).toHaveBeenCalledWith({
|
|
244
|
+
url: '/Profile',
|
|
245
|
+
})
|
|
246
|
+
})
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
it('should handle multiple params', async () => {
|
|
250
|
+
const navigationRef = createMockNavigationRef('Search', {
|
|
251
|
+
query: 'test',
|
|
252
|
+
page: 1,
|
|
253
|
+
})
|
|
254
|
+
|
|
255
|
+
renderHook(
|
|
256
|
+
() =>
|
|
257
|
+
useNavigationTracking({
|
|
258
|
+
navigationRef: navigationRef as any,
|
|
259
|
+
includeParams: true,
|
|
260
|
+
}),
|
|
261
|
+
{ wrapper: createWrapper() },
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
await waitFor(() => {
|
|
265
|
+
const call = mockTrackPageview.mock.calls[0][0]
|
|
266
|
+
expect(call.url).toContain('/Search?')
|
|
267
|
+
expect(call.url).toContain('query=test')
|
|
268
|
+
expect(call.url).toContain('page=1')
|
|
269
|
+
})
|
|
270
|
+
})
|
|
271
|
+
|
|
272
|
+
it('should encode special characters in params', async () => {
|
|
273
|
+
const navigationRef = createMockNavigationRef('Search', {
|
|
274
|
+
query: 'hello world',
|
|
275
|
+
})
|
|
276
|
+
|
|
277
|
+
renderHook(
|
|
278
|
+
() =>
|
|
279
|
+
useNavigationTracking({
|
|
280
|
+
navigationRef: navigationRef as any,
|
|
281
|
+
includeParams: true,
|
|
282
|
+
}),
|
|
283
|
+
{ wrapper: createWrapper() },
|
|
284
|
+
)
|
|
285
|
+
|
|
286
|
+
await waitFor(() => {
|
|
287
|
+
expect(mockTrackPageview).toHaveBeenCalledWith({
|
|
288
|
+
url: '/Search?query=hello%20world',
|
|
289
|
+
})
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
it('should skip null and undefined params', async () => {
|
|
294
|
+
const navigationRef = createMockNavigationRef('Profile', {
|
|
295
|
+
userId: '123',
|
|
296
|
+
extra: null,
|
|
297
|
+
other: undefined,
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
renderHook(
|
|
301
|
+
() =>
|
|
302
|
+
useNavigationTracking({
|
|
303
|
+
navigationRef: navigationRef as any,
|
|
304
|
+
includeParams: true,
|
|
305
|
+
}),
|
|
306
|
+
{ wrapper: createWrapper() },
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
await waitFor(() => {
|
|
310
|
+
expect(mockTrackPageview).toHaveBeenCalledWith({
|
|
311
|
+
url: '/Profile?userId=123',
|
|
312
|
+
})
|
|
313
|
+
})
|
|
314
|
+
})
|
|
315
|
+
|
|
316
|
+
it('should pass params to shouldTrackRoute', async () => {
|
|
317
|
+
const shouldTrackRoute = vi.fn(() => true)
|
|
318
|
+
const params = { userId: '123' }
|
|
319
|
+
const navigationRef = createMockNavigationRef('Profile', params)
|
|
320
|
+
|
|
321
|
+
renderHook(
|
|
322
|
+
() =>
|
|
323
|
+
useNavigationTracking({
|
|
324
|
+
navigationRef: navigationRef as any,
|
|
325
|
+
shouldTrackRoute,
|
|
326
|
+
includeParams: true, // Need this for params to be passed
|
|
327
|
+
}),
|
|
328
|
+
{ wrapper: createWrapper() },
|
|
329
|
+
)
|
|
330
|
+
|
|
331
|
+
await waitFor(() => {
|
|
332
|
+
expect(shouldTrackRoute).toHaveBeenCalledWith('Profile', params)
|
|
333
|
+
})
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it('should handle navigation ref without getRootState', async () => {
|
|
337
|
+
const navigationRef = {
|
|
338
|
+
current: {
|
|
339
|
+
getRootState: undefined,
|
|
340
|
+
addListener: vi.fn(() => vi.fn()),
|
|
341
|
+
},
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
// Should not throw
|
|
345
|
+
renderHook(
|
|
346
|
+
() =>
|
|
347
|
+
useNavigationTracking({
|
|
348
|
+
navigationRef: navigationRef as any,
|
|
349
|
+
}),
|
|
350
|
+
{ wrapper: createWrapper() },
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
354
|
+
|
|
355
|
+
expect(mockTrackPageview).not.toHaveBeenCalled()
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('should handle null navigation ref current', async () => {
|
|
359
|
+
const navigationRef = {
|
|
360
|
+
current: null,
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// Should not throw
|
|
364
|
+
renderHook(
|
|
365
|
+
() =>
|
|
366
|
+
useNavigationTracking({
|
|
367
|
+
navigationRef: navigationRef as any,
|
|
368
|
+
}),
|
|
369
|
+
{ wrapper: createWrapper() },
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
await new Promise((resolve) => setTimeout(resolve, 50))
|
|
373
|
+
|
|
374
|
+
expect(mockTrackPageview).not.toHaveBeenCalled()
|
|
375
|
+
})
|
|
376
|
+
|
|
377
|
+
describe('nested navigation', () => {
|
|
378
|
+
it('should track deepest route in nested navigators', async () => {
|
|
379
|
+
const navigationRef = {
|
|
380
|
+
current: {
|
|
381
|
+
getRootState: () => ({
|
|
382
|
+
index: 0,
|
|
383
|
+
routes: [
|
|
384
|
+
{
|
|
385
|
+
name: 'MainStack',
|
|
386
|
+
state: {
|
|
387
|
+
index: 1,
|
|
388
|
+
routes: [
|
|
389
|
+
{ name: 'Home' },
|
|
390
|
+
{
|
|
391
|
+
name: 'TabNavigator',
|
|
392
|
+
state: {
|
|
393
|
+
index: 0,
|
|
394
|
+
routes: [{ name: 'Feed' }],
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
],
|
|
398
|
+
},
|
|
399
|
+
},
|
|
400
|
+
],
|
|
401
|
+
}),
|
|
402
|
+
addListener: vi.fn(() => vi.fn()),
|
|
403
|
+
},
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
renderHook(
|
|
407
|
+
() =>
|
|
408
|
+
useNavigationTracking({
|
|
409
|
+
navigationRef: navigationRef as any,
|
|
410
|
+
}),
|
|
411
|
+
{ wrapper: createWrapper() },
|
|
412
|
+
)
|
|
413
|
+
|
|
414
|
+
await waitFor(() => {
|
|
415
|
+
expect(mockTrackPageview).toHaveBeenCalledWith({
|
|
416
|
+
url: '/Feed',
|
|
417
|
+
})
|
|
418
|
+
})
|
|
419
|
+
})
|
|
420
|
+
})
|
|
421
|
+
|
|
422
|
+
describe('combined options', () => {
|
|
423
|
+
it('should work with all options combined', async () => {
|
|
424
|
+
const navigationRef = createMockNavigationRef('UserProfile', {
|
|
425
|
+
id: '456',
|
|
426
|
+
})
|
|
427
|
+
|
|
428
|
+
renderHook(
|
|
429
|
+
() =>
|
|
430
|
+
useNavigationTracking({
|
|
431
|
+
navigationRef: navigationRef as any,
|
|
432
|
+
transformRouteName: (name) => `/app/${name}`,
|
|
433
|
+
shouldTrackRoute: () => true,
|
|
434
|
+
includeParams: true,
|
|
435
|
+
}),
|
|
436
|
+
{ wrapper: createWrapper() },
|
|
437
|
+
)
|
|
438
|
+
|
|
439
|
+
await waitFor(() => {
|
|
440
|
+
expect(mockTrackPageview).toHaveBeenCalledWith({
|
|
441
|
+
url: '/app/UserProfile?id=456',
|
|
442
|
+
})
|
|
443
|
+
})
|
|
444
|
+
})
|
|
445
|
+
})
|
|
446
|
+
})
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
import { useEffect, useRef, useCallback } from 'react'
|
|
2
|
+
|
|
3
|
+
import { useFathom } from '../hooks/useFathom'
|
|
4
|
+
import type { UseNavigationTrackingOptions } from './types'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Hook that tracks screen navigation as pageviews using React Navigation.
|
|
8
|
+
*
|
|
9
|
+
* This integrates with React Navigation's navigation container to automatically
|
|
10
|
+
* track screen changes as Fathom pageviews.
|
|
11
|
+
*
|
|
12
|
+
* @example
|
|
13
|
+
* ```tsx
|
|
14
|
+
* import { NavigationContainer } from '@react-navigation/native'
|
|
15
|
+
* import { useNavigationTracking } from 'react-fathom/native'
|
|
16
|
+
*
|
|
17
|
+
* function App() {
|
|
18
|
+
* const navigationRef = useNavigationContainerRef()
|
|
19
|
+
*
|
|
20
|
+
* useNavigationTracking({
|
|
21
|
+
* navigationRef,
|
|
22
|
+
* transformRouteName: (name) => `/screens/${name}`,
|
|
23
|
+
* })
|
|
24
|
+
*
|
|
25
|
+
* return (
|
|
26
|
+
* <NavigationContainer ref={navigationRef}>
|
|
27
|
+
* <Navigator />
|
|
28
|
+
* </NavigationContainer>
|
|
29
|
+
* )
|
|
30
|
+
* }
|
|
31
|
+
* ```
|
|
32
|
+
*/
|
|
33
|
+
export function useNavigationTracking(options: UseNavigationTrackingOptions) {
|
|
34
|
+
const {
|
|
35
|
+
navigationRef,
|
|
36
|
+
transformRouteName,
|
|
37
|
+
shouldTrackRoute,
|
|
38
|
+
includeParams = false,
|
|
39
|
+
} = options
|
|
40
|
+
|
|
41
|
+
const { trackPageview } = useFathom()
|
|
42
|
+
const routeNameRef = useRef<string | undefined>(undefined)
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Get the current route name from the navigation state
|
|
46
|
+
*/
|
|
47
|
+
const getCurrentRouteName = useCallback((): string | undefined => {
|
|
48
|
+
if (!navigationRef.current) {
|
|
49
|
+
return undefined
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const state = navigationRef.current.getRootState?.()
|
|
53
|
+
if (!state) {
|
|
54
|
+
return undefined
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Navigate through nested navigators to get the deepest route
|
|
58
|
+
let currentState = state
|
|
59
|
+
while (currentState.routes[currentState.index]?.state) {
|
|
60
|
+
currentState = currentState.routes[currentState.index].state as any
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
return currentState.routes[currentState.index]?.name
|
|
64
|
+
}, [navigationRef])
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Get the current route params from the navigation state
|
|
68
|
+
*/
|
|
69
|
+
const getCurrentRouteParams = useCallback((): Record<string, any> | undefined => {
|
|
70
|
+
if (!navigationRef.current) {
|
|
71
|
+
return undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
const state = navigationRef.current.getRootState?.()
|
|
75
|
+
if (!state) {
|
|
76
|
+
return undefined
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Navigate through nested navigators to get the deepest route
|
|
80
|
+
let currentState = state
|
|
81
|
+
while (currentState.routes[currentState.index]?.state) {
|
|
82
|
+
currentState = currentState.routes[currentState.index].state as any
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return currentState.routes[currentState.index]?.params as Record<string, any> | undefined
|
|
86
|
+
}, [navigationRef])
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Build the URL to track
|
|
90
|
+
*/
|
|
91
|
+
const buildTrackingUrl = useCallback(
|
|
92
|
+
(routeName: string, params?: Record<string, any>): string => {
|
|
93
|
+
let url = transformRouteName ? transformRouteName(routeName) : `/${routeName}`
|
|
94
|
+
|
|
95
|
+
if (includeParams && params && Object.keys(params).length > 0) {
|
|
96
|
+
const queryString = Object.entries(params)
|
|
97
|
+
.filter(([_, value]) => value !== undefined && value !== null)
|
|
98
|
+
.map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`)
|
|
99
|
+
.join('&')
|
|
100
|
+
|
|
101
|
+
if (queryString) {
|
|
102
|
+
url += `?${queryString}`
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return url
|
|
107
|
+
},
|
|
108
|
+
[transformRouteName, includeParams],
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Handle navigation state change
|
|
113
|
+
*/
|
|
114
|
+
const handleStateChange = useCallback(() => {
|
|
115
|
+
const currentRouteName = getCurrentRouteName()
|
|
116
|
+
const previousRouteName = routeNameRef.current
|
|
117
|
+
|
|
118
|
+
if (currentRouteName && currentRouteName !== previousRouteName) {
|
|
119
|
+
const params = includeParams ? getCurrentRouteParams() : undefined
|
|
120
|
+
|
|
121
|
+
// Check if this route should be tracked
|
|
122
|
+
if (shouldTrackRoute && !shouldTrackRoute(currentRouteName, params)) {
|
|
123
|
+
routeNameRef.current = currentRouteName
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const url = buildTrackingUrl(currentRouteName, params)
|
|
128
|
+
|
|
129
|
+
trackPageview?.({
|
|
130
|
+
url,
|
|
131
|
+
referrer: previousRouteName ? buildTrackingUrl(previousRouteName) : undefined,
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
routeNameRef.current = currentRouteName
|
|
135
|
+
}
|
|
136
|
+
}, [
|
|
137
|
+
getCurrentRouteName,
|
|
138
|
+
getCurrentRouteParams,
|
|
139
|
+
shouldTrackRoute,
|
|
140
|
+
buildTrackingUrl,
|
|
141
|
+
trackPageview,
|
|
142
|
+
includeParams,
|
|
143
|
+
])
|
|
144
|
+
|
|
145
|
+
// Track initial route on mount
|
|
146
|
+
useEffect(() => {
|
|
147
|
+
// Small delay to ensure navigation is ready
|
|
148
|
+
const timeout = setTimeout(() => {
|
|
149
|
+
const initialRoute = getCurrentRouteName()
|
|
150
|
+
if (initialRoute) {
|
|
151
|
+
const params = includeParams ? getCurrentRouteParams() : undefined
|
|
152
|
+
|
|
153
|
+
if (!shouldTrackRoute || shouldTrackRoute(initialRoute, params)) {
|
|
154
|
+
const url = buildTrackingUrl(initialRoute, params)
|
|
155
|
+
trackPageview?.({ url })
|
|
156
|
+
routeNameRef.current = initialRoute
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
}, 0)
|
|
160
|
+
|
|
161
|
+
return () => clearTimeout(timeout)
|
|
162
|
+
}, []) // Only on mount
|
|
163
|
+
|
|
164
|
+
// Set up navigation state change listener
|
|
165
|
+
useEffect(() => {
|
|
166
|
+
if (!navigationRef.current) {
|
|
167
|
+
return
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Listen for navigation state changes
|
|
171
|
+
const unsubscribe = navigationRef.current.addListener?.('state', handleStateChange)
|
|
172
|
+
|
|
173
|
+
return () => {
|
|
174
|
+
unsubscribe?.()
|
|
175
|
+
}
|
|
176
|
+
}, [navigationRef, handleStateChange])
|
|
177
|
+
}
|