Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/calm-tools-know.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'@posthog/react': minor
'posthog-js': minor
---

feat: add a component that will wrap your components and capture an event when they are in view in the browser
6 changes: 6 additions & 0 deletions .changeset/rotten-tools-eat.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
---
'posthog-js': patch
'@posthog/react': patch
---

fix: complete react sdk featureflag component refactor
3 changes: 2 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,8 @@ The recommended workflow for testing local changes uses tarballs, which most rea
1. Run `pnpm turbo package` inside the root folder to generate tarballs in `./target`
2. Navigate to the example/playground project: `cd examples/example-nextjs`
3. Run `pnpm install` (remove `pnpm-lock.yaml` if it exists) to install local tarballs
4. Run `pnpm dev` or `pnpm start` to start the project
4. you might need to point package.json at the tarball like `"posthog-js": "file:../../target/posthog-js.tgz",`
5. Run `pnpm dev` or `pnpm start` to start the project

### Development Workflow (Recommended)

Expand Down
3 changes: 3 additions & 0 deletions packages/browser/src/__tests__/segment.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ describe(`Segment integration`, () => {
})
},
}

// logging of network requests during init causes this to flake
console.error = jest.fn()
})

it('should call loaded after the segment integration has been set up', async () => {
Expand Down
101 changes: 8 additions & 93 deletions packages/react/src/components/PostHogFeature.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useFeatureFlagPayload, useFeatureFlagVariantKey, usePostHog } from '../hooks'
import React, { Children, ReactNode, useCallback, useEffect, useRef } from 'react'
import React from 'react'
import { PostHog } from '../context'
import { isFunction, isNull, isUndefined } from '../utils/type-utils'
import { isFunction, isUndefined } from '../utils/type-utils'
import { VisibilityAndClickTrackers } from './internal/VisibilityAndClickTrackers'

export type PostHogFeatureProps = React.HTMLProps<HTMLDivElement> & {
flag: string
Expand All @@ -25,6 +26,7 @@ export function PostHogFeature({
}: PostHogFeatureProps): JSX.Element | null {
const payload = useFeatureFlagPayload(flag)
const variant = useFeatureFlagVariantKey(flag)
const posthog = usePostHog()

const shouldTrackInteraction = trackInteraction ?? true
const shouldTrackView = trackView ?? true
Expand All @@ -37,6 +39,8 @@ export function PostHogFeature({
options={visibilityObserverOptions}
trackInteraction={shouldTrackInteraction}
trackView={shouldTrackView}
onInteract={() => captureFeatureInteraction({ flag, posthog, flagVariant: variant })}
onView={() => captureFeatureView({ flag, posthog, flagVariant: variant })}
{...props}
>
{childNode}
Expand All @@ -46,7 +50,7 @@ export function PostHogFeature({
return <>{fallback}</>
}

function captureFeatureInteraction({
export function captureFeatureInteraction({
flag,
posthog,
flagVariant,
Expand All @@ -65,7 +69,7 @@ function captureFeatureInteraction({
posthog.capture('$feature_interaction', properties)
}

function captureFeatureView({
export function captureFeatureView({
flag,
posthog,
flagVariant,
Expand All @@ -83,92 +87,3 @@ function captureFeatureView({
}
posthog.capture('$feature_view', properties)
}

function VisibilityAndClickTracker({
flag,
children,
onIntersect,
onClick,
trackView,
options,
...props
}: {
flag: string
children: React.ReactNode
onIntersect: (entry: IntersectionObserverEntry) => void
onClick: () => void
trackView: boolean
options?: IntersectionObserverInit
}): JSX.Element {
const ref = useRef<HTMLDivElement>(null)
const posthog = usePostHog()

useEffect(() => {
if (isNull(ref.current) || !trackView) return

// eslint-disable-next-line compat/compat
const observer = new IntersectionObserver(([entry]) => onIntersect(entry), {
threshold: 0.1,
...options,
})
observer.observe(ref.current)
return () => observer.disconnect()
}, [flag, options, posthog, ref, trackView, onIntersect])

return (
<div ref={ref} {...props} onClick={onClick}>
{children}
</div>
)
}

function VisibilityAndClickTrackers({
flag,
children,
trackInteraction,
trackView,
options,
...props
}: {
flag: string
children: React.ReactNode
trackInteraction: boolean
trackView: boolean
options?: IntersectionObserverInit
}): JSX.Element {
const clickTrackedRef = useRef(false)
const visibilityTrackedRef = useRef(false)
const posthog = usePostHog()
const variant = useFeatureFlagVariantKey(flag)

const cachedOnClick = useCallback(() => {
if (!clickTrackedRef.current && trackInteraction) {
captureFeatureInteraction({ flag, posthog, flagVariant: variant })
clickTrackedRef.current = true
}
}, [flag, posthog, trackInteraction, variant])

const onIntersect = (entry: IntersectionObserverEntry) => {
if (!visibilityTrackedRef.current && entry.isIntersecting) {
captureFeatureView({ flag, posthog, flagVariant: variant })
visibilityTrackedRef.current = true
}
}

const trackedChildren = Children.map(children, (child: ReactNode) => {
return (
<VisibilityAndClickTracker
flag={flag}
onClick={cachedOnClick}
onIntersect={onIntersect}
trackView={trackView}
options={options}
{...props}
>
{child}
</VisibilityAndClickTracker>
)
})

return <>{trackedChildren}</>
}
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { isNull } from '../../utils/type-utils'
* VisibilityAndClickTracker is an internal component,
* its API might change without warning and without being signalled as a breaking change
*
* Wraps the provided children in a div, and tracks visibility of and clicks on that div
*/
export function VisibilityAndClickTracker({
children,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,48 +1,60 @@
import React, { MouseEventHandler, useEffect, useMemo, useRef } from 'react'
import { isNull } from '../../utils/type-utils'
import React, { Children, ReactNode, useCallback, useRef } from 'react'
import { VisibilityAndClickTracker } from './VisibilityAndClickTracker'

/**
* VisibilityAndClickTracker is an internal component,
* VisibilityAndClickTrackers is an internal component,
* its API might change without warning and without being signalled as a breaking change
*
* Wraps each of the children passed to it for visiblity and click tracking
*
*/
export function VisibilityAndClickTracker({
export function VisibilityAndClickTrackers({
children,
onIntersect,
onClick,
trackInteraction,
trackView,
options,
onInteract,
onView,
...props
}: {
flag: string
children: React.ReactNode
onIntersect: (entry: IntersectionObserverEntry) => void
onClick?: MouseEventHandler<HTMLDivElement>
trackInteraction: boolean
trackView: boolean
options?: IntersectionObserverInit
onInteract?: () => void
onView?: () => void
}): JSX.Element {
const ref = useRef<HTMLDivElement>(null)
const clickTrackedRef = useRef(false)
const visibilityTrackedRef = useRef(false)

const observerOptions = useMemo(
() => ({
threshold: 0.1,
...options,
}),
// eslint-disable-next-line react-hooks/exhaustive-deps
[options?.threshold, options?.root, options?.rootMargin]
)
const cachedOnClick = useCallback(() => {
if (!clickTrackedRef.current && trackInteraction && onInteract) {
onInteract()
clickTrackedRef.current = true
}
}, [trackInteraction, onInteract])

useEffect(() => {
if (isNull(ref.current) || !trackView) return
const onIntersect = (entry: IntersectionObserverEntry) => {
if (!visibilityTrackedRef.current && entry.isIntersecting && onView) {
onView()
visibilityTrackedRef.current = true
}
}

// eslint-disable-next-line compat/compat
const observer = new IntersectionObserver(([entry]) => onIntersect(entry), observerOptions)
observer.observe(ref.current)
return () => observer.disconnect()
}, [observerOptions, trackView, onIntersect])
const trackedChildren = Children.map(children, (child: ReactNode) => {
return (
<VisibilityAndClickTracker
onClick={cachedOnClick}
onIntersect={onIntersect}
trackView={trackView}
options={options}
{...props}
>
{child}
</VisibilityAndClickTracker>
)
})

return (
<div ref={ref} {...props} onClick={onClick}>
{children}
</div>
)
return <>{trackedChildren}</>
}
46 changes: 4 additions & 42 deletions playground/react-nextjs/app/EventDisplay.tsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,14 @@
'use client'

import { CaptureResult } from 'posthog-js'
import { useState, useEffect } from 'react'
import { useState } from 'react'

interface EventDisplayProps {
events: CaptureResult[]
}

interface EventWithTimestamp extends CaptureResult {
capturedAt: number
}

export function EventDisplay({ events }: EventDisplayProps) {
const [expandedEvents, setExpandedEvents] = useState<Set<string>>(new Set())
const [eventsWithTimestamp, setEventsWithTimestamp] = useState<EventWithTimestamp[]>([])
const [, setTick] = useState(0)

useEffect(() => {
const newEvents = events.filter((e) => !eventsWithTimestamp.find((existing) => existing.uuid === e.uuid))
if (newEvents.length > 0) {
setEventsWithTimestamp((prev) => [...prev, ...newEvents.map((e) => ({ ...e, capturedAt: Date.now() }))])
}
}, [events, eventsWithTimestamp])

useEffect(() => {
const interval = setInterval(() => setTick((t) => t + 1), 1000)
return () => clearInterval(interval)
}, [])

const toggleExpanded = (uuid: string) => {
setExpandedEvents((prev) => {
Expand All @@ -40,15 +22,6 @@ export function EventDisplay({ events }: EventDisplayProps) {
})
}

const getTimeAgo = (timestamp: number) => {
const seconds = Math.floor((Date.now() - timestamp) / 1000)
if (seconds < 60) return `${seconds}s ago`
const minutes = Math.floor(seconds / 60)
if (minutes < 60) return `${minutes}m ago`
const hours = Math.floor(minutes / 60)
return `${hours}h ago`
}

return (
<div
style={{
Expand All @@ -69,11 +42,11 @@ export function EventDisplay({ events }: EventDisplayProps) {
<h2 style={{ fontSize: '1.125rem', fontWeight: 'bold', marginBottom: '0.75rem', color: '#1f2937' }}>
PostHog Events
</h2>
{eventsWithTimestamp.length === 0 ? (
{events.length === 0 ? (
<p style={{ color: '#6b7280', fontSize: '0.875rem' }}>No events captured yet...</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{eventsWithTimestamp.map((event) => (
{events.map((event) => (
<div
key={event.uuid}
style={{
Expand All @@ -86,18 +59,7 @@ export function EventDisplay({ events }: EventDisplayProps) {
}}
onClick={() => toggleExpanded(event.uuid)}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<div style={{ fontWeight: '600', color: '#2563eb' }}>{event.event}</div>
<div style={{ color: '#9ca3af', fontSize: '0.65rem' }}>
{getTimeAgo(event.capturedAt)}
</div>
</div>
<div style={{ fontWeight: '600', color: '#2563eb' }}>{event.event}</div>
{expandedEvents.has(event.uuid) && (
<div
style={{
Expand Down
Loading
Loading