Skip to content

Commit c4fa36a

Browse files
committed
feat: add a component to capture when wrapped components are in view in the browser
1 parent 7caa257 commit c4fa36a

File tree

3 files changed

+237
-0
lines changed

3 files changed

+237
-0
lines changed
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
import React, { Children, useCallback, useRef } from 'react'
2+
import { usePostHog } from '../hooks'
3+
import { VisibilityAndClickTracker } from './internal/VisibilityAndClickTracker'
4+
5+
export type PostHogCaptureOnViewedProps = React.HTMLProps<HTMLDivElement> & {
6+
name?: string
7+
properties?: Record<string, any>
8+
observerOptions?: IntersectionObserverInit
9+
trackAllChildren?: boolean
10+
}
11+
12+
function TrackedChild({
13+
child,
14+
index,
15+
name,
16+
properties,
17+
observerOptions,
18+
}: {
19+
child: React.ReactNode
20+
index: number
21+
name?: string
22+
properties?: Record<string, any>
23+
observerOptions?: IntersectionObserverInit
24+
}): JSX.Element {
25+
const trackedRef = useRef(false)
26+
const posthog = usePostHog()
27+
28+
const onIntersect = useCallback(
29+
(entry: IntersectionObserverEntry) => {
30+
if (entry.isIntersecting && !trackedRef.current) {
31+
posthog.capture('$element_viewed', {
32+
element_name: name,
33+
child_index: index,
34+
...properties,
35+
})
36+
trackedRef.current = true
37+
}
38+
},
39+
[posthog, name, index, properties]
40+
)
41+
42+
return (
43+
<VisibilityAndClickTracker onIntersect={onIntersect} trackView={true} options={observerOptions}>
44+
{child}
45+
</VisibilityAndClickTracker>
46+
)
47+
}
48+
49+
/**
50+
* PostHogCaptureOnViewed - Track when elements are scrolled into view
51+
*
52+
* Wraps any children and automatically sends a `$element_viewed` event to PostHog
53+
* when the element comes into the viewport. Only fires once per component instance.
54+
*
55+
* @example
56+
* ```tsx
57+
* <PostHogCaptureOnViewed name="hero-banner">
58+
* <div>Important content here</div>
59+
* </PostHogCaptureOnViewed>
60+
*
61+
* // With custom properties
62+
* <PostHogCaptureOnViewed
63+
* name="product-card"
64+
* properties={{ product_id: '123', category: 'electronics' }}
65+
* >
66+
* <ProductCard />
67+
* </PostHogCaptureOnViewed>
68+
*
69+
* // With custom intersection observer options
70+
* <PostHogCaptureOnViewed
71+
* name="footer"
72+
* observerOptions={{ threshold: 0.5 }}
73+
* >
74+
* <Footer />
75+
* </PostHogCaptureOnViewed>
76+
* ```
77+
*/
78+
export function PostHogCaptureOnViewed({
79+
name,
80+
properties,
81+
observerOptions,
82+
trackAllChildren,
83+
children,
84+
...props
85+
}: PostHogCaptureOnViewedProps): JSX.Element {
86+
const trackedRef = useRef(false)
87+
const posthog = usePostHog()
88+
89+
const onIntersect = useCallback(
90+
(entry: IntersectionObserverEntry) => {
91+
if (entry.isIntersecting && !trackedRef.current) {
92+
posthog.capture('$element_viewed', {
93+
element_name: name,
94+
...properties,
95+
})
96+
trackedRef.current = true
97+
}
98+
},
99+
[posthog, name, properties]
100+
)
101+
102+
// If trackAllChildren is enabled, wrap each child individually
103+
if (trackAllChildren) {
104+
const trackedChildren = Children.map(children, (child, index) => {
105+
return (
106+
<TrackedChild
107+
key={index}
108+
child={child}
109+
index={index}
110+
name={name}
111+
properties={properties}
112+
observerOptions={observerOptions}
113+
/>
114+
)
115+
})
116+
117+
return <div {...props}>{trackedChildren}</div>
118+
}
119+
120+
// Default behavior: track the container as a single element
121+
return (
122+
<VisibilityAndClickTracker onIntersect={onIntersect} trackView={true} options={observerOptions} {...props}>
123+
{children}
124+
</VisibilityAndClickTracker>
125+
)
126+
}
Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,110 @@
1+
import * as React from 'react'
2+
import { render, screen } from '@testing-library/react'
3+
import { PostHog, PostHogProvider } from '../../context'
4+
import { PostHogCaptureOnViewed } from '../'
5+
import '@testing-library/jest-dom'
6+
7+
describe('PostHogCaptureOnViewed component', () => {
8+
let mockObserverCallback: any = null
9+
10+
let fakePosthog: PostHog
11+
12+
beforeEach(() => {
13+
fakePosthog = {
14+
capture: jest.fn(),
15+
} as unknown as PostHog
16+
17+
const mockIntersectionObserver = jest.fn((callback) => {
18+
mockObserverCallback = callback
19+
return {
20+
observe: jest.fn(),
21+
unobserve: jest.fn(),
22+
disconnect: jest.fn(),
23+
}
24+
})
25+
26+
mockIntersectionObserver.prototype = {}
27+
// eslint-disable-next-line compat/compat
28+
window.IntersectionObserver = mockIntersectionObserver as unknown as typeof IntersectionObserver
29+
})
30+
31+
it('should render children', () => {
32+
render(
33+
<PostHogProvider client={fakePosthog}>
34+
<PostHogCaptureOnViewed name="test-element">
35+
<div data-testid="child">Hello</div>
36+
</PostHogCaptureOnViewed>
37+
</PostHogProvider>
38+
)
39+
40+
expect(screen.getByTestId('child')).toBeInTheDocument()
41+
})
42+
43+
it('should track when element comes into view', () => {
44+
render(
45+
<PostHogProvider client={fakePosthog}>
46+
<PostHogCaptureOnViewed name="test-element">
47+
<div data-testid="child">Hello</div>
48+
</PostHogCaptureOnViewed>
49+
</PostHogProvider>
50+
)
51+
52+
expect(fakePosthog.capture).not.toHaveBeenCalled()
53+
54+
mockObserverCallback([{ isIntersecting: true }])
55+
56+
expect(fakePosthog.capture).toHaveBeenCalledWith('$element_viewed', {
57+
element_name: 'test-element',
58+
})
59+
expect(fakePosthog.capture).toHaveBeenCalledTimes(1)
60+
})
61+
62+
it('should only track visibility once', () => {
63+
render(
64+
<PostHogProvider client={fakePosthog}>
65+
<PostHogCaptureOnViewed name="test-element">
66+
<div data-testid="child">Hello</div>
67+
</PostHogCaptureOnViewed>
68+
</PostHogProvider>
69+
)
70+
71+
mockObserverCallback([{ isIntersecting: true }])
72+
expect(fakePosthog.capture).toHaveBeenCalledTimes(1)
73+
74+
mockObserverCallback([{ isIntersecting: true }])
75+
mockObserverCallback([{ isIntersecting: true }])
76+
expect(fakePosthog.capture).toHaveBeenCalledTimes(1)
77+
})
78+
79+
it('should include custom properties', () => {
80+
render(
81+
<PostHogProvider client={fakePosthog}>
82+
<PostHogCaptureOnViewed name="test-element" properties={{ category: 'hero', priority: 'high' }}>
83+
<div data-testid="child">Hello</div>
84+
</PostHogCaptureOnViewed>
85+
</PostHogProvider>
86+
)
87+
88+
mockObserverCallback([{ isIntersecting: true }])
89+
90+
expect(fakePosthog.capture).toHaveBeenCalledWith('$element_viewed', {
91+
element_name: 'test-element',
92+
category: 'hero',
93+
priority: 'high',
94+
})
95+
})
96+
97+
it('should not track when element is not intersecting', () => {
98+
render(
99+
<PostHogProvider client={fakePosthog}>
100+
<PostHogCaptureOnViewed name="test-element">
101+
<div data-testid="child">Hello</div>
102+
</PostHogCaptureOnViewed>
103+
</PostHogProvider>
104+
)
105+
106+
mockObserverCallback([{ isIntersecting: false }])
107+
108+
expect(fakePosthog.capture).not.toHaveBeenCalled()
109+
})
110+
})

packages/react/src/components/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
export * from './PostHogFeature'
2+
export * from './PostHogCaptureOnViewed'
23
export {
34
PostHogErrorBoundary,
45
PostHogErrorBoundaryProps,

0 commit comments

Comments
 (0)