react-fathom 0.1.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 (96) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +199 -0
  3. package/dist/cjs/index.cjs +410 -0
  4. package/dist/cjs/index.cjs.map +1 -0
  5. package/dist/cjs/next/index.cjs +910 -0
  6. package/dist/cjs/next/index.cjs.map +1 -0
  7. package/dist/es/index.js +381 -0
  8. package/dist/es/index.js.map +1 -0
  9. package/dist/es/next/index.js +885 -0
  10. package/dist/es/next/index.js.map +1 -0
  11. package/dist/react-fathom.js +413 -0
  12. package/dist/react-fathom.js.map +1 -0
  13. package/dist/react-fathom.min.js +3 -0
  14. package/dist/react-fathom.min.js.map +1 -0
  15. package/package.json +127 -0
  16. package/src/FathomContext.tsx +5 -0
  17. package/src/FathomProvider.test.tsx +532 -0
  18. package/src/FathomProvider.tsx +122 -0
  19. package/src/components/TrackClick.test.tsx +191 -0
  20. package/src/components/TrackClick.tsx +62 -0
  21. package/src/components/TrackPageview.test.tsx +111 -0
  22. package/src/components/TrackPageview.tsx +36 -0
  23. package/src/components/TrackVisible.test.tsx +311 -0
  24. package/src/components/TrackVisible.tsx +105 -0
  25. package/src/components/index.ts +3 -0
  26. package/src/hooks/index.ts +4 -0
  27. package/src/hooks/useFathom.test.tsx +51 -0
  28. package/src/hooks/useFathom.ts +11 -0
  29. package/src/hooks/useTrackOnClick.test.tsx +197 -0
  30. package/src/hooks/useTrackOnClick.ts +65 -0
  31. package/src/hooks/useTrackOnMount.test.tsx +79 -0
  32. package/src/hooks/useTrackOnMount.ts +24 -0
  33. package/src/hooks/useTrackOnVisible.test.tsx +313 -0
  34. package/src/hooks/useTrackOnVisible.ts +99 -0
  35. package/src/index.ts +4 -0
  36. package/src/next/NextFathomProvider.test.tsx +131 -0
  37. package/src/next/NextFathomProvider.tsx +62 -0
  38. package/src/next/NextFathomProviderApp.test.tsx +308 -0
  39. package/src/next/NextFathomProviderApp.tsx +106 -0
  40. package/src/next/NextFathomProviderPages.test.tsx +330 -0
  41. package/src/next/NextFathomProviderPages.tsx +112 -0
  42. package/src/next/compositions/withAppRouter.test.tsx +113 -0
  43. package/src/next/compositions/withAppRouter.tsx +48 -0
  44. package/src/next/compositions/withPagesRouter.test.tsx +113 -0
  45. package/src/next/compositions/withPagesRouter.tsx +44 -0
  46. package/src/next/index.ts +7 -0
  47. package/src/next/types.ts +19 -0
  48. package/src/types.ts +37 -0
  49. package/types/FathomContext.d.ts +3 -0
  50. package/types/FathomContext.d.ts.map +1 -0
  51. package/types/FathomProvider.d.ts +5 -0
  52. package/types/FathomProvider.d.ts.map +1 -0
  53. package/types/components/TrackClick.d.ts +39 -0
  54. package/types/components/TrackClick.d.ts.map +1 -0
  55. package/types/components/TrackPageview.d.ts +21 -0
  56. package/types/components/TrackPageview.d.ts.map +1 -0
  57. package/types/components/TrackVisible.d.ts +39 -0
  58. package/types/components/TrackVisible.d.ts.map +1 -0
  59. package/types/components/index.d.ts +4 -0
  60. package/types/components/index.d.ts.map +1 -0
  61. package/types/hooks/index.d.ts +5 -0
  62. package/types/hooks/index.d.ts.map +1 -0
  63. package/types/hooks/useFathom.d.ts +6 -0
  64. package/types/hooks/useFathom.d.ts.map +1 -0
  65. package/types/hooks/useTrackOnClick.d.ts +39 -0
  66. package/types/hooks/useTrackOnClick.d.ts.map +1 -0
  67. package/types/hooks/useTrackOnMount.d.ts +14 -0
  68. package/types/hooks/useTrackOnMount.d.ts.map +1 -0
  69. package/types/hooks/useTrackOnVisible.d.ts +43 -0
  70. package/types/hooks/useTrackOnVisible.d.ts.map +1 -0
  71. package/types/index.d.ts +5 -0
  72. package/types/index.d.ts.map +1 -0
  73. package/types/next/AppRouterProvider.d.ts +7 -0
  74. package/types/next/AppRouterProvider.d.ts.map +1 -0
  75. package/types/next/NextFathomProvider.d.ts +34 -0
  76. package/types/next/NextFathomProvider.d.ts.map +1 -0
  77. package/types/next/NextFathomProviderApp.d.ts +6 -0
  78. package/types/next/NextFathomProviderApp.d.ts.map +1 -0
  79. package/types/next/NextFathomProviderPages.d.ts +6 -0
  80. package/types/next/NextFathomProviderPages.d.ts.map +1 -0
  81. package/types/next/PagesRouterProvider.d.ts +7 -0
  82. package/types/next/PagesRouterProvider.d.ts.map +1 -0
  83. package/types/next/compositions/withAppRouter.d.ts +29 -0
  84. package/types/next/compositions/withAppRouter.d.ts.map +1 -0
  85. package/types/next/compositions/withPagesRouter.d.ts +25 -0
  86. package/types/next/compositions/withPagesRouter.d.ts.map +1 -0
  87. package/types/next/index.d.ts +6 -0
  88. package/types/next/index.d.ts.map +1 -0
  89. package/types/next/types.d.ts +16 -0
  90. package/types/next/types.d.ts.map +1 -0
  91. package/types/test-setup.d.ts +2 -0
  92. package/types/test-setup.d.ts.map +1 -0
  93. package/types/types.d.ts +34 -0
  94. package/types/types.d.ts.map +1 -0
  95. package/types/useFathom.d.ts +7 -0
  96. package/types/useFathom.d.ts.map +1 -0
@@ -0,0 +1,330 @@
1
+ import React from 'react'
2
+
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ import { renderHook, waitFor } from '@testing-library/react'
6
+
7
+ import NextFathomProviderPages from './NextFathomProviderPages'
8
+ import { useFathom } from '../hooks/useFathom'
9
+
10
+ // Mock Next.js Pages Router hook
11
+ const mockRouter = {
12
+ pathname: '/test-page',
13
+ query: { foo: 'bar' },
14
+ asPath: '/test-page?foo=bar',
15
+ isReady: true,
16
+ events: {
17
+ on: vi.fn(),
18
+ off: vi.fn(),
19
+ },
20
+ }
21
+
22
+ vi.mock('next/router', () => ({
23
+ useRouter: vi.fn(() => mockRouter),
24
+ }))
25
+
26
+ // Mock fathom-client
27
+ vi.mock('fathom-client', async () => {
28
+ const mockFathomClient = {
29
+ trackEvent: vi.fn(),
30
+ trackPageview: vi.fn(),
31
+ trackGoal: vi.fn(),
32
+ load: vi.fn(),
33
+ setSite: vi.fn(),
34
+ blockTrackingForMe: vi.fn(),
35
+ enableTrackingForMe: vi.fn(),
36
+ isTrackingEnabled: vi.fn(() => true),
37
+ }
38
+
39
+ return {
40
+ default: mockFathomClient,
41
+ }
42
+ })
43
+
44
+ describe('NextFathomProviderPages', () => {
45
+ const mockClient = {
46
+ trackEvent: vi.fn(),
47
+ trackPageview: vi.fn(),
48
+ trackGoal: vi.fn(),
49
+ load: vi.fn(),
50
+ setSite: vi.fn(),
51
+ blockTrackingForMe: vi.fn(),
52
+ enableTrackingForMe: vi.fn(),
53
+ isTrackingEnabled: vi.fn(() => true),
54
+ }
55
+
56
+ beforeEach(() => {
57
+ vi.clearAllMocks()
58
+ mockRouter.isReady = true
59
+ mockRouter.events.on = vi.fn()
60
+ mockRouter.events.off = vi.fn()
61
+ // Reset window.location
62
+ Object.defineProperty(window, 'location', {
63
+ value: {
64
+ origin: 'https://example.com',
65
+ href: 'https://example.com/test-page?foo=bar',
66
+ },
67
+ writable: true,
68
+ })
69
+ })
70
+
71
+ it('should load Fathom on mount when siteId is provided', async () => {
72
+ const loadSpy = vi.fn()
73
+ const client = { ...mockClient, load: loadSpy }
74
+
75
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
76
+ <NextFathomProviderPages client={client} siteId="TEST_SITE_ID">
77
+ {children}
78
+ </NextFathomProviderPages>
79
+ )
80
+
81
+ renderHook(() => useFathom(), { wrapper })
82
+
83
+ await waitFor(() => {
84
+ expect(loadSpy).toHaveBeenCalledWith('TEST_SITE_ID', undefined)
85
+ })
86
+ })
87
+
88
+ it('should load Fathom with clientOptions', async () => {
89
+ const loadSpy = vi.fn()
90
+ const clientOptions = { honorDNT: true }
91
+ const client = { ...mockClient, load: loadSpy }
92
+
93
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
94
+ <NextFathomProviderPages
95
+ client={client}
96
+ siteId="TEST_SITE_ID"
97
+ clientOptions={clientOptions}
98
+ >
99
+ {children}
100
+ </NextFathomProviderPages>
101
+ )
102
+
103
+ renderHook(() => useFathom(), { wrapper })
104
+
105
+ await waitFor(() => {
106
+ expect(loadSpy).toHaveBeenCalledWith('TEST_SITE_ID', clientOptions)
107
+ })
108
+ })
109
+
110
+ it('should track initial pageview when router is ready', async () => {
111
+ const trackPageviewSpy = vi.fn()
112
+ const client = { ...mockClient, trackPageview: trackPageviewSpy }
113
+
114
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
115
+ <NextFathomProviderPages client={client} siteId="TEST_SITE_ID">
116
+ {children}
117
+ </NextFathomProviderPages>
118
+ )
119
+
120
+ renderHook(() => useFathom(), { wrapper })
121
+
122
+ await waitFor(() => {
123
+ expect(trackPageviewSpy).toHaveBeenCalled()
124
+ })
125
+
126
+ expect(trackPageviewSpy).toHaveBeenCalledWith({
127
+ url: 'https://example.com/test-page?foo=bar',
128
+ })
129
+ })
130
+
131
+ it('should not track initial pageview when router is not ready', async () => {
132
+ mockRouter.isReady = false
133
+ const trackPageviewSpy = vi.fn()
134
+ const client = { ...mockClient, trackPageview: trackPageviewSpy }
135
+
136
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
137
+ <NextFathomProviderPages client={client} siteId="TEST_SITE_ID">
138
+ {children}
139
+ </NextFathomProviderPages>
140
+ )
141
+
142
+ renderHook(() => useFathom(), { wrapper })
143
+
144
+ // Wait a bit to ensure no tracking happens
145
+ await new Promise((resolve) => setTimeout(resolve, 100))
146
+
147
+ expect(trackPageviewSpy).not.toHaveBeenCalled()
148
+ })
149
+
150
+ it('should register route change listener', async () => {
151
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
152
+ <NextFathomProviderPages client={mockClient} siteId="TEST_SITE_ID">
153
+ {children}
154
+ </NextFathomProviderPages>
155
+ )
156
+
157
+ renderHook(() => useFathom(), { wrapper })
158
+
159
+ await waitFor(() => {
160
+ expect(mockRouter.events.on).toHaveBeenCalledWith(
161
+ 'routeChangeComplete',
162
+ expect.any(Function),
163
+ )
164
+ })
165
+ })
166
+
167
+ it('should unregister route change listener on unmount', async () => {
168
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
169
+ <NextFathomProviderPages client={mockClient} siteId="TEST_SITE_ID">
170
+ {children}
171
+ </NextFathomProviderPages>
172
+ )
173
+
174
+ const { unmount } = renderHook(() => useFathom(), { wrapper })
175
+
176
+ await waitFor(() => {
177
+ expect(mockRouter.events.on).toHaveBeenCalled()
178
+ })
179
+
180
+ unmount()
181
+
182
+ expect(mockRouter.events.off).toHaveBeenCalledWith(
183
+ 'routeChangeComplete',
184
+ expect.any(Function),
185
+ )
186
+ })
187
+
188
+ it('should track pageview on route change', async () => {
189
+ const trackPageviewSpy = vi.fn()
190
+ const client = { ...mockClient, trackPageview: trackPageviewSpy }
191
+
192
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
193
+ <NextFathomProviderPages client={client} siteId="TEST_SITE_ID">
194
+ {children}
195
+ </NextFathomProviderPages>
196
+ )
197
+
198
+ renderHook(() => useFathom(), { wrapper })
199
+
200
+ await waitFor(() => {
201
+ expect(mockRouter.events.on).toHaveBeenCalled()
202
+ })
203
+
204
+ // Get the route change handler
205
+ const routeChangeHandler = mockRouter.events.on.mock.calls[0][1]
206
+ routeChangeHandler('/new-page')
207
+
208
+ expect(trackPageviewSpy).toHaveBeenCalledWith({
209
+ url: 'https://example.com/new-page',
210
+ })
211
+ })
212
+
213
+ it('should not track pageview when disableAutoTrack is true', async () => {
214
+ const trackPageviewSpy = vi.fn()
215
+ const client = { ...mockClient, trackPageview: trackPageviewSpy }
216
+
217
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
218
+ <NextFathomProviderPages
219
+ client={client}
220
+ siteId="TEST_SITE_ID"
221
+ disableAutoTrack
222
+ >
223
+ {children}
224
+ </NextFathomProviderPages>
225
+ )
226
+
227
+ renderHook(() => useFathom(), { wrapper })
228
+
229
+ // Wait a bit to ensure no tracking happens
230
+ await new Promise((resolve) => setTimeout(resolve, 100))
231
+
232
+ expect(trackPageviewSpy).not.toHaveBeenCalled()
233
+ expect(mockRouter.events.on).not.toHaveBeenCalled()
234
+ })
235
+
236
+ it('should use provided client', () => {
237
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
238
+ <NextFathomProviderPages client={mockClient} siteId="TEST_SITE_ID">
239
+ {children}
240
+ </NextFathomProviderPages>
241
+ )
242
+
243
+ const { result } = renderHook(() => useFathom(), { wrapper })
244
+
245
+ expect(result.current.client).toBe(mockClient)
246
+ })
247
+
248
+ it('should merge defaultPageviewOptions', async () => {
249
+ const trackPageviewSpy = vi.fn()
250
+ const client = { ...mockClient, trackPageview: trackPageviewSpy }
251
+
252
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
253
+ <NextFathomProviderPages
254
+ client={client}
255
+ siteId="TEST_SITE_ID"
256
+ defaultPageviewOptions={{ referrer: 'https://example.com' }}
257
+ >
258
+ {children}
259
+ </NextFathomProviderPages>
260
+ )
261
+
262
+ renderHook(() => useFathom(), { wrapper })
263
+
264
+ await waitFor(() => {
265
+ expect(trackPageviewSpy).toHaveBeenCalled()
266
+ })
267
+
268
+ expect(trackPageviewSpy).toHaveBeenCalledWith({
269
+ referrer: 'https://example.com',
270
+ url: 'https://example.com/test-page?foo=bar',
271
+ })
272
+ })
273
+
274
+ it('should support deprecated trackDefaultOptions', async () => {
275
+ const trackPageviewSpy = vi.fn()
276
+ const client = { ...mockClient, trackPageview: trackPageviewSpy }
277
+
278
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
279
+ <NextFathomProviderPages
280
+ client={client}
281
+ siteId="TEST_SITE_ID"
282
+ trackDefaultOptions={{ referrer: 'https://example.com' }}
283
+ >
284
+ {children}
285
+ </NextFathomProviderPages>
286
+ )
287
+
288
+ renderHook(() => useFathom(), { wrapper })
289
+
290
+ await waitFor(() => {
291
+ expect(trackPageviewSpy).toHaveBeenCalled()
292
+ })
293
+
294
+ expect(trackPageviewSpy).toHaveBeenCalledWith({
295
+ referrer: 'https://example.com',
296
+ url: 'https://example.com/test-page?foo=bar',
297
+ })
298
+ })
299
+
300
+ it('should prioritize defaultPageviewOptions over trackDefaultOptions', async () => {
301
+ const trackPageviewSpy = vi.fn()
302
+ const client = { ...mockClient, trackPageview: trackPageviewSpy }
303
+
304
+ const wrapper = ({ children }: { children: React.ReactNode }) => (
305
+ <NextFathomProviderPages
306
+ client={client}
307
+ siteId="TEST_SITE_ID"
308
+ trackDefaultOptions={{ referrer: 'old' }}
309
+ defaultPageviewOptions={{ referrer: 'new' }}
310
+ >
311
+ {children}
312
+ </NextFathomProviderPages>
313
+ )
314
+
315
+ renderHook(() => useFathom(), { wrapper })
316
+
317
+ await waitFor(() => {
318
+ expect(trackPageviewSpy).toHaveBeenCalled()
319
+ })
320
+
321
+ expect(trackPageviewSpy).toHaveBeenCalledWith({
322
+ referrer: 'new',
323
+ url: 'https://example.com/test-page?foo=bar',
324
+ })
325
+ })
326
+
327
+ it('should have displayName', () => {
328
+ expect(NextFathomProviderPages.displayName).toBe('NextFathomProviderPages')
329
+ })
330
+ })
@@ -0,0 +1,112 @@
1
+ import React, { useCallback, useEffect, useRef, useMemo } from 'react'
2
+
3
+ import * as Fathom from 'fathom-client'
4
+ import type { PageViewOptions } from 'fathom-client'
5
+ import { useRouter } from 'next/router'
6
+
7
+ import { FathomProvider } from '../FathomProvider'
8
+ import type { NextFathomProviderProps } from './types'
9
+ import { useFathom } from '../hooks/useFathom'
10
+
11
+ const NextFathomProviderPages: React.FC<NextFathomProviderProps> = ({
12
+ children,
13
+ client: providedClient,
14
+ clientOptions,
15
+ disableAutoTrack = false,
16
+ siteId,
17
+ trackDefaultOptions,
18
+ defaultPageviewOptions: providedDefaultPageviewOptions,
19
+ }) => {
20
+ const router = useRouter()
21
+ const hasTrackedInitialPageview = useRef(false)
22
+ const parentContext = useFathom()
23
+
24
+ // Use provided client or fall back to parent client or default Fathom
25
+ const client = useMemo(
26
+ () => providedClient ?? parentContext.client ?? Fathom,
27
+ [providedClient, parentContext.client],
28
+ )
29
+
30
+ // Support both deprecated trackDefaultOptions and new defaultPageviewOptions
31
+ // Priority: providedDefaultPageviewOptions > trackDefaultOptions > parent defaultPageviewOptions
32
+ const defaultPageviewOptions = useMemo(
33
+ () =>
34
+ providedDefaultPageviewOptions ??
35
+ trackDefaultOptions ??
36
+ parentContext.defaultPageviewOptions,
37
+ [
38
+ providedDefaultPageviewOptions,
39
+ trackDefaultOptions,
40
+ parentContext.defaultPageviewOptions,
41
+ ],
42
+ )
43
+
44
+ const trackPageview = useCallback(
45
+ (options?: PageViewOptions) => {
46
+ if (siteId !== undefined && client !== undefined) {
47
+ client.trackPageview({
48
+ ...defaultPageviewOptions,
49
+ ...options,
50
+ })
51
+ }
52
+ },
53
+ [client, siteId, defaultPageviewOptions],
54
+ )
55
+
56
+ // Initialize Fathom on mount
57
+ useEffect(() => {
58
+ if (siteId !== undefined && client !== undefined) {
59
+ client.load(siteId, clientOptions)
60
+ }
61
+ }, [client, clientOptions, siteId])
62
+
63
+ // Track pageviews on route changes
64
+ useEffect(() => {
65
+ if (siteId === undefined || disableAutoTrack) {
66
+ return
67
+ }
68
+
69
+ const handleRouteChangeComplete = (url: string): void => {
70
+ trackPageview({
71
+ url: window.location.origin + url,
72
+ })
73
+ }
74
+
75
+ router.events.on('routeChangeComplete', handleRouteChangeComplete)
76
+
77
+ return () => {
78
+ router.events.off('routeChangeComplete', handleRouteChangeComplete)
79
+ }
80
+ }, [router.events, siteId, disableAutoTrack, trackPageview])
81
+
82
+ // Track initial pageview (routeChangeComplete doesn't fire on initial load)
83
+ useEffect(() => {
84
+ if (
85
+ siteId !== undefined &&
86
+ !disableAutoTrack &&
87
+ router.isReady &&
88
+ !hasTrackedInitialPageview.current
89
+ ) {
90
+ hasTrackedInitialPageview.current = true
91
+ trackPageview({
92
+ url: window.location.href,
93
+ })
94
+ }
95
+ }, [siteId, disableAutoTrack, router.isReady, trackPageview])
96
+
97
+ return (
98
+ <FathomProvider
99
+ client={client}
100
+ clientOptions={clientOptions}
101
+ siteId={siteId}
102
+ defaultPageviewOptions={defaultPageviewOptions}
103
+ >
104
+ {children}
105
+ </FathomProvider>
106
+ )
107
+ }
108
+
109
+ NextFathomProviderPages.displayName = 'NextFathomProviderPages'
110
+
111
+ export default NextFathomProviderPages
112
+ export { NextFathomProviderPages }
@@ -0,0 +1,113 @@
1
+ import React from 'react'
2
+
3
+ import { beforeEach, describe, expect, it, vi } from 'vitest'
4
+
5
+ import { render, screen } from '@testing-library/react'
6
+
7
+ import { withAppRouter } from './withAppRouter'
8
+
9
+ // Mock NextFathomProviderApp
10
+ vi.mock('../NextFathomProviderApp', () => ({
11
+ default: ({
12
+ children,
13
+ siteId,
14
+ }: {
15
+ children: React.ReactNode
16
+ siteId?: string
17
+ }) => (
18
+ <div data-testid="app-provider" data-site-id={siteId}>
19
+ {children}
20
+ </div>
21
+ ),
22
+ }))
23
+
24
+ describe('withAppRouter', () => {
25
+ beforeEach(() => {
26
+ vi.clearAllMocks()
27
+ })
28
+
29
+ it('should wrap component with NextFathomProviderApp', () => {
30
+ const TestComponent = ({ name }: { name: string }) => (
31
+ <div>Hello {name}</div>
32
+ )
33
+
34
+ const WrappedComponent = withAppRouter(TestComponent, {
35
+ siteId: 'TEST_SITE_ID',
36
+ })
37
+
38
+ render(<WrappedComponent name="World" />)
39
+
40
+ expect(screen.getByTestId('app-provider')).toBeInTheDocument()
41
+ expect(screen.getByTestId('app-provider')).toHaveAttribute(
42
+ 'data-site-id',
43
+ 'TEST_SITE_ID',
44
+ )
45
+ expect(screen.getByText('Hello World')).toBeInTheDocument()
46
+ })
47
+
48
+ it('should pass props to wrapped component', () => {
49
+ const TestComponent = ({ count }: { count: number }) => (
50
+ <div>Count: {count}</div>
51
+ )
52
+
53
+ const WrappedComponent = withAppRouter(TestComponent)
54
+
55
+ render(<WrappedComponent count={42} />)
56
+
57
+ expect(screen.getByText('Count: 42')).toBeInTheDocument()
58
+ })
59
+
60
+ it('should pass provider props to NextFathomProviderApp', () => {
61
+ const TestComponent = () => <div>Test</div>
62
+
63
+ const WrappedComponent = withAppRouter(TestComponent, {
64
+ siteId: 'TEST_SITE_ID',
65
+ disableAutoTrack: true,
66
+ clientOptions: { honorDNT: true },
67
+ })
68
+
69
+ render(<WrappedComponent />)
70
+
71
+ expect(screen.getByTestId('app-provider')).toBeInTheDocument()
72
+ })
73
+
74
+ it('should work without provider props', () => {
75
+ const TestComponent = () => <div>Test</div>
76
+
77
+ const WrappedComponent = withAppRouter(TestComponent)
78
+
79
+ render(<WrappedComponent />)
80
+
81
+ expect(screen.getByTestId('app-provider')).toBeInTheDocument()
82
+ expect(screen.getByText('Test')).toBeInTheDocument()
83
+ })
84
+
85
+ it('should set displayName correctly', () => {
86
+ const TestComponent = () => <div>Test</div>
87
+ TestComponent.displayName = 'TestComponent'
88
+
89
+ const WrappedComponent = withAppRouter(TestComponent)
90
+
91
+ expect(WrappedComponent.displayName).toBe('withAppRouter(TestComponent)')
92
+ })
93
+
94
+ it('should set displayName with component name when displayName is not set', () => {
95
+ function TestComponent() {
96
+ return <div>Test</div>
97
+ }
98
+
99
+ const WrappedComponent = withAppRouter(TestComponent)
100
+
101
+ expect(WrappedComponent.displayName).toBe('withAppRouter(TestComponent)')
102
+ })
103
+
104
+ it('should set displayName with Component fallback', () => {
105
+ const TestComponent = () => <div>Test</div>
106
+ // Remove displayName and name
107
+ Object.defineProperty(TestComponent, 'name', { value: '' })
108
+
109
+ const WrappedComponent = withAppRouter(TestComponent)
110
+
111
+ expect(WrappedComponent.displayName).toBe('withAppRouter(Component)')
112
+ })
113
+ })
@@ -0,0 +1,48 @@
1
+ import React from 'react'
2
+ import type { ComponentType } from 'react'
3
+
4
+ import NextFathomProviderApp from '../NextFathomProviderApp'
5
+ import type { NextFathomProviderProps } from '../types'
6
+
7
+ /**
8
+ * Higher-order component that wraps your Next.js App Router app with FathomProvider
9
+ *
10
+ * @example
11
+ * ```tsx
12
+ * // app/layout.tsx
13
+ * import { withAppRouter } from 'react-fathom/next'
14
+ *
15
+ * function RootLayout({ children }) {
16
+ * return (
17
+ * <html>
18
+ * <body>{children}</body>
19
+ * </html>
20
+ * )
21
+ * }
22
+ *
23
+ * export default withAppRouter(RootLayout, {
24
+ * siteId: 'YOUR_SITE_ID',
25
+ * clientOptions: {
26
+ * spa: 'auto',
27
+ * },
28
+ * })
29
+ * ```
30
+ */
31
+ const withAppRouter = <P extends object>(
32
+ Component: ComponentType<P>,
33
+ providerProps?: NextFathomProviderProps,
34
+ ): ComponentType<P> => {
35
+ const WithAppRouter: React.FC<P> = (props) => {
36
+ return (
37
+ <NextFathomProviderApp {...providerProps}>
38
+ <Component {...props} />
39
+ </NextFathomProviderApp>
40
+ )
41
+ }
42
+
43
+ WithAppRouter.displayName = `withAppRouter(${Component.displayName || Component.name || 'Component'})`
44
+
45
+ return WithAppRouter
46
+ }
47
+
48
+ export { withAppRouter }