@hanzo/insights-react 1.8.1
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/LICENSE +21 -0
- package/README.md +198 -0
- package/dist/esm/index.js +426 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/surveys/index.js +98 -0
- package/dist/esm/surveys/index.js.map +1 -0
- package/dist/types/index.d.ts +96 -0
- package/dist/types/surveys/index.d.ts +19 -0
- package/dist/umd/index.js +449 -0
- package/dist/umd/index.js.map +1 -0
- package/dist/umd/surveys/index.js +107 -0
- package/dist/umd/surveys/index.js.map +1 -0
- package/package.json +64 -0
- package/src/components/PostHogCaptureOnViewed.tsx +126 -0
- package/src/components/PostHogErrorBoundary.tsx +89 -0
- package/src/components/PostHogFeature.tsx +91 -0
- package/src/components/__tests__/PostHogCaptureOnViewed.test.tsx +110 -0
- package/src/components/__tests__/PostHogErrorBoundary.test.tsx +110 -0
- package/src/components/__tests__/PostHogFeature.test.tsx +291 -0
- package/src/components/index.ts +7 -0
- package/src/components/internal/VisibilityAndClickTracker.tsx +49 -0
- package/src/components/internal/VisibilityAndClickTrackers.tsx +60 -0
- package/src/context/PostHogContext.ts +9 -0
- package/src/context/PostHogProvider.tsx +136 -0
- package/src/context/__tests__/PostHogContext.test.tsx +35 -0
- package/src/context/__tests__/PostHogProvider.test.tsx +131 -0
- package/src/context/index.ts +2 -0
- package/src/helpers/error-helpers.ts +15 -0
- package/src/helpers/index.ts +1 -0
- package/src/hooks/__tests__/featureFlags.test.tsx +273 -0
- package/src/hooks/__tests__/usePostHog.test.tsx +19 -0
- package/src/hooks/__tests__/useThumbSurvey.test.tsx +105 -0
- package/src/hooks/index.ts +6 -0
- package/src/hooks/useActiveFeatureFlags.ts +21 -0
- package/src/hooks/useFeatureFlagEnabled.ts +24 -0
- package/src/hooks/useFeatureFlagPayload.ts +22 -0
- package/src/hooks/useFeatureFlagResult.ts +31 -0
- package/src/hooks/useFeatureFlagVariantKey.ts +22 -0
- package/src/hooks/usePostHog.ts +7 -0
- package/src/hooks/useThumbSurvey.ts +146 -0
- package/src/index.ts +4 -0
- package/src/surveys/index.ts +1 -0
- package/src/utils/__tests__/object-utils.test.ts +42 -0
- package/src/utils/object-utils.ts +36 -0
- package/src/utils/type-utils.ts +16 -0
- package/surveys/package.json +7 -0
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { useState } from 'react'
|
|
3
|
+
import { render, screen, fireEvent } from '@testing-library/react'
|
|
4
|
+
import { PostHogProvider, PostHog } from '../../context'
|
|
5
|
+
import { PostHogFeature } from '../'
|
|
6
|
+
import '@testing-library/jest-dom'
|
|
7
|
+
|
|
8
|
+
const FEATURE_FLAG_STATUS: Record<string, string | boolean> = {
|
|
9
|
+
multivariate_feature: 'string-value',
|
|
10
|
+
example_feature_payload: 'test',
|
|
11
|
+
test: true,
|
|
12
|
+
test_false: false,
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const FEATURE_FLAG_PAYLOADS: Record<string, any> = {
|
|
16
|
+
example_feature_payload: {
|
|
17
|
+
id: 1,
|
|
18
|
+
name: 'example_feature_1_payload',
|
|
19
|
+
key: 'example_feature_1_payload',
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('PostHogFeature component', () => {
|
|
24
|
+
let posthog: PostHog
|
|
25
|
+
|
|
26
|
+
const renderWith = (instance: PostHog, flag = 'test', matchValue: string | boolean | undefined = true) =>
|
|
27
|
+
render(
|
|
28
|
+
<PostHogProvider client={instance}>
|
|
29
|
+
<PostHogFeature flag={flag} match={matchValue}>
|
|
30
|
+
<div data-testid="helloDiv">Hello</div>
|
|
31
|
+
</PostHogFeature>
|
|
32
|
+
</PostHogProvider>
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
beforeEach(() => {
|
|
36
|
+
// IntersectionObserver isn't available in test environment
|
|
37
|
+
const mockIntersectionObserver = jest.fn()
|
|
38
|
+
mockIntersectionObserver.mockReturnValue({
|
|
39
|
+
observe: () => null,
|
|
40
|
+
unobserve: () => null,
|
|
41
|
+
disconnect: () => null,
|
|
42
|
+
})
|
|
43
|
+
|
|
44
|
+
// eslint-disable-next-line compat/compat
|
|
45
|
+
window.IntersectionObserver = mockIntersectionObserver
|
|
46
|
+
|
|
47
|
+
posthog = {
|
|
48
|
+
isFeatureEnabled: (flag: string) => !!FEATURE_FLAG_STATUS[flag],
|
|
49
|
+
getFeatureFlag: (flag: string) => FEATURE_FLAG_STATUS[flag],
|
|
50
|
+
getFeatureFlagPayload: (flag: string) => FEATURE_FLAG_PAYLOADS[flag],
|
|
51
|
+
onFeatureFlags: (callback: any) => {
|
|
52
|
+
const activeFlags: string[] = []
|
|
53
|
+
for (const flag in FEATURE_FLAG_STATUS) {
|
|
54
|
+
if (FEATURE_FLAG_STATUS[flag]) {
|
|
55
|
+
activeFlags.push(flag)
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
callback(activeFlags)
|
|
59
|
+
return () => {}
|
|
60
|
+
},
|
|
61
|
+
capture: jest.fn(),
|
|
62
|
+
featureFlags: {
|
|
63
|
+
hasLoadedFlags: true,
|
|
64
|
+
},
|
|
65
|
+
} as unknown as PostHog
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('should track interactions with the feature component', () => {
|
|
69
|
+
renderWith(posthog)
|
|
70
|
+
|
|
71
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
72
|
+
expect(posthog.capture).toHaveBeenCalledWith('$feature_interaction', {
|
|
73
|
+
feature_flag: 'test',
|
|
74
|
+
$set: { '$feature_interaction/test': true },
|
|
75
|
+
})
|
|
76
|
+
expect(posthog.capture).toHaveBeenCalledTimes(1)
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('should not fire for every interaction with the feature component', () => {
|
|
80
|
+
renderWith(posthog)
|
|
81
|
+
|
|
82
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
83
|
+
expect(posthog.capture).toHaveBeenCalledWith('$feature_interaction', {
|
|
84
|
+
feature_flag: 'test',
|
|
85
|
+
$set: { '$feature_interaction/test': true },
|
|
86
|
+
})
|
|
87
|
+
expect(posthog.capture).toHaveBeenCalledTimes(1)
|
|
88
|
+
|
|
89
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
90
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
91
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
92
|
+
expect(posthog.capture).toHaveBeenCalledTimes(1)
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
it('should track an interaction with each child node of the feature component', () => {
|
|
96
|
+
render(
|
|
97
|
+
<PostHogProvider client={posthog}>
|
|
98
|
+
<PostHogFeature flag={'test'} match={true}>
|
|
99
|
+
<div data-testid="helloDiv">Hello</div>
|
|
100
|
+
<div data-testid="worldDiv">World!</div>
|
|
101
|
+
</PostHogFeature>
|
|
102
|
+
</PostHogProvider>
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
106
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
107
|
+
fireEvent.click(screen.getByTestId('worldDiv'))
|
|
108
|
+
fireEvent.click(screen.getByTestId('worldDiv'))
|
|
109
|
+
fireEvent.click(screen.getByTestId('worldDiv'))
|
|
110
|
+
expect(posthog.capture).toHaveBeenCalledWith('$feature_interaction', {
|
|
111
|
+
feature_flag: 'test',
|
|
112
|
+
$set: { '$feature_interaction/test': true },
|
|
113
|
+
})
|
|
114
|
+
expect(posthog.capture).toHaveBeenCalledTimes(1)
|
|
115
|
+
})
|
|
116
|
+
|
|
117
|
+
it('should not fire events when interaction is disabled', () => {
|
|
118
|
+
render(
|
|
119
|
+
<PostHogProvider client={posthog}>
|
|
120
|
+
<PostHogFeature flag={'test'} match={true} trackInteraction={false}>
|
|
121
|
+
<div data-testid="helloDiv">Hello</div>
|
|
122
|
+
</PostHogFeature>
|
|
123
|
+
</PostHogProvider>
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
127
|
+
expect(posthog.capture).not.toHaveBeenCalled()
|
|
128
|
+
|
|
129
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
130
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
131
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
132
|
+
expect(posthog.capture).not.toHaveBeenCalled()
|
|
133
|
+
})
|
|
134
|
+
|
|
135
|
+
it('should fire events when interaction is disabled but re-enabled after', () => {
|
|
136
|
+
const DynamicUpdateComponent = () => {
|
|
137
|
+
const [trackInteraction, setTrackInteraction] = useState(false)
|
|
138
|
+
|
|
139
|
+
return (
|
|
140
|
+
<>
|
|
141
|
+
<div
|
|
142
|
+
data-testid="clicker"
|
|
143
|
+
onClick={() => {
|
|
144
|
+
setTrackInteraction(true)
|
|
145
|
+
}}
|
|
146
|
+
>
|
|
147
|
+
Click me
|
|
148
|
+
</div>
|
|
149
|
+
<PostHogFeature flag={'test'} match={true} trackInteraction={trackInteraction}>
|
|
150
|
+
<div data-testid="helloDiv">Hello</div>
|
|
151
|
+
</PostHogFeature>
|
|
152
|
+
</>
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
render(
|
|
157
|
+
<PostHogProvider client={posthog}>
|
|
158
|
+
<DynamicUpdateComponent />
|
|
159
|
+
</PostHogProvider>
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
163
|
+
expect(posthog.capture).not.toHaveBeenCalled()
|
|
164
|
+
|
|
165
|
+
fireEvent.click(screen.getByTestId('clicker'))
|
|
166
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
167
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
168
|
+
expect(posthog.capture).toHaveBeenCalledWith('$feature_interaction', {
|
|
169
|
+
feature_flag: 'test',
|
|
170
|
+
$set: { '$feature_interaction/test': true },
|
|
171
|
+
})
|
|
172
|
+
expect(posthog.capture).toHaveBeenCalledTimes(1)
|
|
173
|
+
})
|
|
174
|
+
|
|
175
|
+
it('should not show the feature component if the flag is not enabled', () => {
|
|
176
|
+
renderWith(posthog, 'test_value')
|
|
177
|
+
|
|
178
|
+
expect(screen.queryByTestId('helloDiv')).not.toBeInTheDocument()
|
|
179
|
+
expect(posthog.capture).not.toHaveBeenCalled()
|
|
180
|
+
|
|
181
|
+
// check if any elements are found
|
|
182
|
+
const allTags = screen.queryAllByText(/.*/)
|
|
183
|
+
|
|
184
|
+
// Assert that no random elements are found
|
|
185
|
+
expect(allTags.length).toEqual(2)
|
|
186
|
+
expect(allTags[0].tagName).toEqual('BODY')
|
|
187
|
+
expect(allTags[1].tagName).toEqual('DIV')
|
|
188
|
+
})
|
|
189
|
+
|
|
190
|
+
it('should fallback when provided', () => {
|
|
191
|
+
render(
|
|
192
|
+
<PostHogProvider client={posthog}>
|
|
193
|
+
<PostHogFeature flag={'test_false'} match={true} fallback={<div data-testid="nope">Nope</div>}>
|
|
194
|
+
<div data-testid="helloDiv">Hello</div>
|
|
195
|
+
</PostHogFeature>
|
|
196
|
+
</PostHogProvider>
|
|
197
|
+
)
|
|
198
|
+
|
|
199
|
+
expect(screen.queryByTestId('helloDiv')).not.toBeInTheDocument()
|
|
200
|
+
expect(posthog.capture).not.toHaveBeenCalled()
|
|
201
|
+
|
|
202
|
+
fireEvent.click(screen.getByTestId('nope'))
|
|
203
|
+
expect(posthog.capture).not.toHaveBeenCalled()
|
|
204
|
+
})
|
|
205
|
+
|
|
206
|
+
it('should handle showing multivariate flags with bool match', () => {
|
|
207
|
+
renderWith(posthog, 'multivariate_feature')
|
|
208
|
+
|
|
209
|
+
expect(screen.queryByTestId('helloDiv')).not.toBeInTheDocument()
|
|
210
|
+
expect(posthog.capture).not.toHaveBeenCalled()
|
|
211
|
+
})
|
|
212
|
+
|
|
213
|
+
it('should handle showing multivariate flags with incorrect match', () => {
|
|
214
|
+
renderWith(posthog, 'multivariate_feature', 'string-valueCXCC')
|
|
215
|
+
|
|
216
|
+
expect(screen.queryByTestId('helloDiv')).not.toBeInTheDocument()
|
|
217
|
+
expect(posthog.capture).not.toHaveBeenCalled()
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
it('should handle showing multivariate flags', () => {
|
|
221
|
+
renderWith(posthog, 'multivariate_feature', 'string-value')
|
|
222
|
+
|
|
223
|
+
expect(screen.queryByTestId('helloDiv')).toBeInTheDocument()
|
|
224
|
+
expect(posthog.capture).not.toHaveBeenCalled()
|
|
225
|
+
|
|
226
|
+
fireEvent.click(screen.getByTestId('helloDiv'))
|
|
227
|
+
expect(posthog.capture).toHaveBeenCalledWith('$feature_interaction', {
|
|
228
|
+
feature_flag: 'multivariate_feature',
|
|
229
|
+
feature_flag_variant: 'string-value',
|
|
230
|
+
$set: { '$feature_interaction/multivariate_feature': 'string-value' },
|
|
231
|
+
})
|
|
232
|
+
expect(posthog.capture).toHaveBeenCalledTimes(1)
|
|
233
|
+
})
|
|
234
|
+
|
|
235
|
+
it('should handle payload flags', () => {
|
|
236
|
+
render(
|
|
237
|
+
<PostHogProvider client={posthog}>
|
|
238
|
+
<PostHogFeature flag={'example_feature_payload'} match={'test'}>
|
|
239
|
+
{(payload: any) => {
|
|
240
|
+
return <div data-testid={`hi_${payload.name}`}>Hullo</div>
|
|
241
|
+
}}
|
|
242
|
+
</PostHogFeature>
|
|
243
|
+
</PostHogProvider>
|
|
244
|
+
)
|
|
245
|
+
|
|
246
|
+
expect(screen.queryByTestId('hi_example_feature_1_payload')).toBeInTheDocument()
|
|
247
|
+
expect(posthog.capture).not.toHaveBeenCalled()
|
|
248
|
+
|
|
249
|
+
fireEvent.click(screen.getByTestId('hi_example_feature_1_payload'))
|
|
250
|
+
expect(posthog.capture).toHaveBeenCalledTimes(1)
|
|
251
|
+
})
|
|
252
|
+
|
|
253
|
+
it('should not render when flag does not exist and no match is specified', () => {
|
|
254
|
+
render(
|
|
255
|
+
<PostHogProvider client={posthog}>
|
|
256
|
+
<PostHogFeature flag={'nonexistent_flag'}>
|
|
257
|
+
<div data-testid="helloDiv">Hello</div>
|
|
258
|
+
</PostHogFeature>
|
|
259
|
+
</PostHogProvider>
|
|
260
|
+
)
|
|
261
|
+
|
|
262
|
+
expect(screen.queryByTestId('helloDiv')).not.toBeInTheDocument()
|
|
263
|
+
expect(posthog.capture).not.toHaveBeenCalled()
|
|
264
|
+
})
|
|
265
|
+
|
|
266
|
+
it('should render fallback when flag does not exist (like new-cta example)', () => {
|
|
267
|
+
render(
|
|
268
|
+
<PostHogProvider client={posthog}>
|
|
269
|
+
<PostHogFeature flag={'new-cta'} fallback={<div data-testid="oldButton">Old Button</div>}>
|
|
270
|
+
<div data-testid="newButton">New Button</div>
|
|
271
|
+
</PostHogFeature>
|
|
272
|
+
</PostHogProvider>
|
|
273
|
+
)
|
|
274
|
+
|
|
275
|
+
expect(screen.queryByTestId('newButton')).not.toBeInTheDocument()
|
|
276
|
+
expect(screen.queryByTestId('oldButton')).toBeInTheDocument()
|
|
277
|
+
expect(posthog.capture).not.toHaveBeenCalled()
|
|
278
|
+
})
|
|
279
|
+
|
|
280
|
+
it('should render content when match=false and flag variant is false', () => {
|
|
281
|
+
render(
|
|
282
|
+
<PostHogProvider client={posthog}>
|
|
283
|
+
<PostHogFeature flag={'test_false'} match={false}>
|
|
284
|
+
<div data-testid="disabledUI">Show when disabled</div>
|
|
285
|
+
</PostHogFeature>
|
|
286
|
+
</PostHogProvider>
|
|
287
|
+
)
|
|
288
|
+
|
|
289
|
+
expect(screen.queryByTestId('disabledUI')).toBeInTheDocument()
|
|
290
|
+
})
|
|
291
|
+
})
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import React, { MouseEventHandler, useEffect, useMemo, useRef, JSX } from 'react'
|
|
2
|
+
import { isNull } from '../../utils/type-utils'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* VisibilityAndClickTracker is an internal component,
|
|
6
|
+
* its API might change without warning and without being signalled as a breaking change
|
|
7
|
+
*
|
|
8
|
+
* Wraps the provided children in a div, and tracks visibility of and clicks on that div
|
|
9
|
+
*/
|
|
10
|
+
export function VisibilityAndClickTracker({
|
|
11
|
+
children,
|
|
12
|
+
onIntersect,
|
|
13
|
+
onClick,
|
|
14
|
+
trackView,
|
|
15
|
+
options,
|
|
16
|
+
...props
|
|
17
|
+
}: {
|
|
18
|
+
children: React.ReactNode
|
|
19
|
+
onIntersect: (entry: IntersectionObserverEntry) => void
|
|
20
|
+
onClick?: MouseEventHandler<HTMLDivElement>
|
|
21
|
+
trackView: boolean
|
|
22
|
+
options?: IntersectionObserverInit
|
|
23
|
+
}): JSX.Element {
|
|
24
|
+
const ref = useRef<HTMLDivElement>(null)
|
|
25
|
+
|
|
26
|
+
const observerOptions = useMemo(
|
|
27
|
+
() => ({
|
|
28
|
+
threshold: 0.1,
|
|
29
|
+
...options,
|
|
30
|
+
}),
|
|
31
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
32
|
+
[options?.threshold, options?.root, options?.rootMargin]
|
|
33
|
+
)
|
|
34
|
+
|
|
35
|
+
useEffect(() => {
|
|
36
|
+
if (isNull(ref.current) || !trackView) return
|
|
37
|
+
|
|
38
|
+
// eslint-disable-next-line compat/compat
|
|
39
|
+
const observer = new IntersectionObserver(([entry]) => onIntersect(entry), observerOptions)
|
|
40
|
+
observer.observe(ref.current)
|
|
41
|
+
return () => observer.disconnect()
|
|
42
|
+
}, [observerOptions, trackView, onIntersect])
|
|
43
|
+
|
|
44
|
+
return (
|
|
45
|
+
<div ref={ref} {...props} onClick={onClick}>
|
|
46
|
+
{children}
|
|
47
|
+
</div>
|
|
48
|
+
)
|
|
49
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import React, { Children, ReactNode, useCallback, useRef, JSX } from 'react'
|
|
2
|
+
import { VisibilityAndClickTracker } from './VisibilityAndClickTracker'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* VisibilityAndClickTrackers is an internal component,
|
|
6
|
+
* its API might change without warning and without being signalled as a breaking change
|
|
7
|
+
*
|
|
8
|
+
* Wraps each of the children passed to it for visiblity and click tracking
|
|
9
|
+
*
|
|
10
|
+
*/
|
|
11
|
+
export function VisibilityAndClickTrackers({
|
|
12
|
+
children,
|
|
13
|
+
trackInteraction,
|
|
14
|
+
trackView,
|
|
15
|
+
options,
|
|
16
|
+
onInteract,
|
|
17
|
+
onView,
|
|
18
|
+
...props
|
|
19
|
+
}: {
|
|
20
|
+
flag: string
|
|
21
|
+
children: React.ReactNode
|
|
22
|
+
trackInteraction: boolean
|
|
23
|
+
trackView: boolean
|
|
24
|
+
options?: IntersectionObserverInit
|
|
25
|
+
onInteract?: () => void
|
|
26
|
+
onView?: () => void
|
|
27
|
+
}): JSX.Element {
|
|
28
|
+
const clickTrackedRef = useRef(false)
|
|
29
|
+
const visibilityTrackedRef = useRef(false)
|
|
30
|
+
|
|
31
|
+
const cachedOnClick = useCallback(() => {
|
|
32
|
+
if (!clickTrackedRef.current && trackInteraction && onInteract) {
|
|
33
|
+
onInteract()
|
|
34
|
+
clickTrackedRef.current = true
|
|
35
|
+
}
|
|
36
|
+
}, [trackInteraction, onInteract])
|
|
37
|
+
|
|
38
|
+
const onIntersect = (entry: IntersectionObserverEntry) => {
|
|
39
|
+
if (!visibilityTrackedRef.current && entry.isIntersecting && onView) {
|
|
40
|
+
onView()
|
|
41
|
+
visibilityTrackedRef.current = true
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const trackedChildren = Children.map(children, (child: ReactNode) => {
|
|
46
|
+
return (
|
|
47
|
+
<VisibilityAndClickTracker
|
|
48
|
+
onClick={cachedOnClick}
|
|
49
|
+
onIntersect={onIntersect}
|
|
50
|
+
trackView={trackView}
|
|
51
|
+
options={options}
|
|
52
|
+
{...props}
|
|
53
|
+
>
|
|
54
|
+
{child}
|
|
55
|
+
</VisibilityAndClickTracker>
|
|
56
|
+
)
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
return <>{trackedChildren}</>
|
|
60
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import posthogJs, { BootstrapConfig } from '@hanzo/insights'
|
|
2
|
+
import { createContext } from 'react'
|
|
3
|
+
|
|
4
|
+
export type PostHog = typeof posthogJs
|
|
5
|
+
|
|
6
|
+
export const PostHogContext = createContext<{ client: PostHog; bootstrap?: BootstrapConfig }>({
|
|
7
|
+
client: posthogJs,
|
|
8
|
+
bootstrap: undefined,
|
|
9
|
+
})
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/* eslint-disable no-console */
|
|
2
|
+
import posthogJs, { PostHogConfig } from '@hanzo/insights'
|
|
3
|
+
|
|
4
|
+
import React, { useEffect, useMemo, useRef } from 'react'
|
|
5
|
+
import { PostHog, PostHogContext } from './PostHogContext'
|
|
6
|
+
import { isDeepEqual } from '../utils/object-utils'
|
|
7
|
+
|
|
8
|
+
interface PreviousInitialization {
|
|
9
|
+
apiKey: string
|
|
10
|
+
options: Partial<PostHogConfig>
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
type WithOptionalChildren<T> = T & { children?: React.ReactNode | undefined }
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Props for the PostHogProvider component.
|
|
17
|
+
* This is a discriminated union type that ensures mutually exclusive props:
|
|
18
|
+
*
|
|
19
|
+
* - If `client` is provided, `apiKey` and `options` must not be provided
|
|
20
|
+
* - If `apiKey` is provided, `client` must not be provided, and `options` is optional
|
|
21
|
+
*/
|
|
22
|
+
type PostHogProviderProps =
|
|
23
|
+
| { client: PostHog; apiKey?: never; options?: never }
|
|
24
|
+
| { apiKey: string; options?: Partial<PostHogConfig>; client?: never }
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* PostHogProvider is a React context provider for PostHog analytics.
|
|
28
|
+
* It can be initialized in two mutually exclusive ways:
|
|
29
|
+
*
|
|
30
|
+
* 1. By providing an existing PostHog `client` instance
|
|
31
|
+
* 2. By providing an `apiKey` (and optionally `options`) to create a new client
|
|
32
|
+
*
|
|
33
|
+
* These initialization methods are mutually exclusive - you must use one or the other,
|
|
34
|
+
* but not both simultaneously.
|
|
35
|
+
*
|
|
36
|
+
* We strongly suggest you memoize the `options` object to ensure that you don't
|
|
37
|
+
* accidentally trigger unnecessary re-renders. We'll properly detect if the options
|
|
38
|
+
* have changed and only call `posthogJs.set_config` if they have, but it's better to
|
|
39
|
+
* avoid unnecessary re-renders in the first place.
|
|
40
|
+
*/
|
|
41
|
+
export function PostHogProvider({ children, client, apiKey, options }: WithOptionalChildren<PostHogProviderProps>) {
|
|
42
|
+
// Used to detect if the client was already initialized
|
|
43
|
+
// This is used to prevent double initialization when running under React.StrictMode
|
|
44
|
+
// We're not storing a simple boolean here because we want to be able to detect if the
|
|
45
|
+
// apiKey or options have changed.
|
|
46
|
+
const previousInitializationRef = useRef<PreviousInitialization | null>(null)
|
|
47
|
+
|
|
48
|
+
const posthog = useMemo(() => {
|
|
49
|
+
if (client) {
|
|
50
|
+
if (apiKey) {
|
|
51
|
+
console.warn(
|
|
52
|
+
'[PostHog.js] You have provided both `client` and `apiKey` to `PostHogProvider`. `apiKey` will be ignored in favour of `client`.'
|
|
53
|
+
)
|
|
54
|
+
}
|
|
55
|
+
if (options) {
|
|
56
|
+
console.warn(
|
|
57
|
+
'[PostHog.js] You have provided both `client` and `options` to `PostHogProvider`. `options` will be ignored in favour of `client`.'
|
|
58
|
+
)
|
|
59
|
+
}
|
|
60
|
+
return client
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (apiKey) {
|
|
64
|
+
// return the global client, we'll initialize it in the useEffect
|
|
65
|
+
return posthogJs
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
console.warn(
|
|
69
|
+
'[PostHog.js] No `apiKey` or `client` were provided to `PostHogProvider`. Using default global `window.posthog` instance. You must initialize it manually. This is not recommended behavior.'
|
|
70
|
+
)
|
|
71
|
+
return posthogJs
|
|
72
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
73
|
+
}, [client, apiKey, JSON.stringify(options)]) // Stringify options to be a stable reference
|
|
74
|
+
|
|
75
|
+
// TRICKY: The init needs to happen in a useEffect rather than useMemo, as useEffect does not happen during SSR. Otherwise
|
|
76
|
+
// we'd end up trying to call posthogJs.init() on the server, which can cause issues around hydration and double-init.
|
|
77
|
+
useEffect(() => {
|
|
78
|
+
if (client) {
|
|
79
|
+
// if the user has passed their own client, assume they will also handle calling init().
|
|
80
|
+
return
|
|
81
|
+
}
|
|
82
|
+
const previousInitialization = previousInitializationRef.current
|
|
83
|
+
|
|
84
|
+
if (!previousInitialization) {
|
|
85
|
+
// If it's the first time running this, but it has been loaded elsewhere, warn the user about it.
|
|
86
|
+
if (posthogJs.__loaded) {
|
|
87
|
+
console.warn('[PostHog.js] `posthog` was already loaded elsewhere. This may cause issues.')
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Init global client
|
|
91
|
+
posthogJs.init(apiKey, options)
|
|
92
|
+
|
|
93
|
+
// Keep track of whether the client was already initialized
|
|
94
|
+
// This is used to prevent double initialization when running under React.StrictMode, and to know when options change
|
|
95
|
+
previousInitializationRef.current = {
|
|
96
|
+
apiKey: apiKey,
|
|
97
|
+
options: options ?? {},
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
// If the client was already initialized, we might still end up running the effect again for a few reasons:
|
|
101
|
+
// * someone is developing locally under `React.StrictMode`
|
|
102
|
+
// * the config has changed
|
|
103
|
+
// * the apiKey has changed (not supported!)
|
|
104
|
+
//
|
|
105
|
+
// Changing the apiKey isn't well supported and we'll simply log a message suggesting them
|
|
106
|
+
// to take control of the `client` initialization themselves. This is tricky to handle
|
|
107
|
+
// ourselves because we wouldn't know if we should call `.reset()` or not, for example.
|
|
108
|
+
if (apiKey !== previousInitialization.apiKey) {
|
|
109
|
+
console.warn(
|
|
110
|
+
"[PostHog.js] You have provided a different `apiKey` to `PostHogProvider` than the one that was already initialized. This is not supported by our provider and we'll keep using the previous key. If you need to toggle between API Keys you need to control the `client` yourself and pass it in as a prop rather than an `apiKey` prop."
|
|
111
|
+
)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Changing options is better supported because we can just call `posthogJs.set_config(options)`
|
|
115
|
+
// and they'll be good to go with their new config. The SDK will know how to handle the changes.
|
|
116
|
+
if (options && !isDeepEqual(options, previousInitialization.options)) {
|
|
117
|
+
posthogJs.set_config(options)
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// Keep track of the possibly-new set of apiKey and options
|
|
121
|
+
previousInitializationRef.current = {
|
|
122
|
+
apiKey: apiKey,
|
|
123
|
+
options: options ?? {},
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
127
|
+
}, [client, apiKey, JSON.stringify(options)]) // Stringify options to be a stable reference
|
|
128
|
+
|
|
129
|
+
return (
|
|
130
|
+
<PostHogContext.Provider
|
|
131
|
+
value={{ client: posthog, bootstrap: options?.bootstrap ?? client?.config?.bootstrap }}
|
|
132
|
+
>
|
|
133
|
+
{children}
|
|
134
|
+
</PostHogContext.Provider>
|
|
135
|
+
)
|
|
136
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { render } from '@testing-library/react'
|
|
3
|
+
import { PostHogProvider, PostHog } from '..'
|
|
4
|
+
|
|
5
|
+
describe('PostHogContext component', () => {
|
|
6
|
+
const posthog = {} as unknown as PostHog
|
|
7
|
+
|
|
8
|
+
it('should return a client instance from the context if available', () => {
|
|
9
|
+
render(
|
|
10
|
+
<PostHogProvider client={posthog}>
|
|
11
|
+
<div>Hello</div>
|
|
12
|
+
</PostHogProvider>
|
|
13
|
+
)
|
|
14
|
+
})
|
|
15
|
+
|
|
16
|
+
it("should not throw error if a client instance can't be found in the context", () => {
|
|
17
|
+
// eslint-disable-next-line no-console
|
|
18
|
+
console.warn = jest.fn()
|
|
19
|
+
|
|
20
|
+
expect(() => {
|
|
21
|
+
render(
|
|
22
|
+
// we have to cast `as any` so that we can test for when
|
|
23
|
+
// posthog might not exist - in SSR for example
|
|
24
|
+
<PostHogProvider client={undefined as any}>
|
|
25
|
+
<div>Hello</div>
|
|
26
|
+
</PostHogProvider>
|
|
27
|
+
)
|
|
28
|
+
}).not.toThrow()
|
|
29
|
+
|
|
30
|
+
// eslint-disable-next-line no-console
|
|
31
|
+
expect(console.warn).toHaveBeenCalledWith(
|
|
32
|
+
'[PostHog.js] No `apiKey` or `client` were provided to `PostHogProvider`. Using default global `window.posthog` instance. You must initialize it manually. This is not recommended behavior.'
|
|
33
|
+
)
|
|
34
|
+
})
|
|
35
|
+
})
|