diff --git a/react/src/components/PostHogFeature.tsx b/react/src/components/PostHogFeature.tsx index d0fbf162d..fa30a8e30 100644 --- a/react/src/components/PostHogFeature.tsx +++ b/react/src/components/PostHogFeature.tsx @@ -1,6 +1,7 @@ import { useFeatureFlagPayload, useFeatureFlagVariantKey, usePostHog } from '../hooks' -import React, { useCallback, useEffect, useRef } from 'react' +import React, { Children, ReactNode, useCallback, useEffect, useRef } from 'react' import { PostHog } from '../context' +import { isFunction, isNull, isUndefined } from '../utils/type-utils' export type PostHogFeatureProps = React.HTMLProps & { flag: string @@ -28,10 +29,10 @@ export function PostHogFeature({ const shouldTrackInteraction = trackInteraction ?? true const shouldTrackView = trackView ?? true - if (match === undefined || variant === match) { - const childNode: React.ReactNode = typeof children === 'function' ? children(payload) : children + if (isUndefined(match) || variant === match) { + const childNode: React.ReactNode = isFunction(children) ? children(payload) : children return ( - {childNode} - + ) } return <>{fallback} @@ -56,38 +57,24 @@ function captureFeatureView(flag: string, posthog: PostHog) { function VisibilityAndClickTracker({ flag, children, - trackInteraction, + onIntersect, + onClick, trackView, options, ...props }: { flag: string children: React.ReactNode - trackInteraction: boolean + onIntersect: (entry: IntersectionObserverEntry) => void + onClick: () => void trackView: boolean options?: IntersectionObserverInit }): JSX.Element { const ref = useRef(null) const posthog = usePostHog() - const visibilityTrackedRef = useRef(false) - const clickTrackedRef = useRef(false) - - const cachedOnClick = useCallback(() => { - if (!clickTrackedRef.current && trackInteraction) { - captureFeatureInteraction(flag, posthog) - clickTrackedRef.current = true - } - }, [flag, posthog, trackInteraction]) useEffect(() => { - if (ref.current === null || !trackView) return - - const onIntersect = (entry: IntersectionObserverEntry) => { - if (!visibilityTrackedRef.current && entry.isIntersecting) { - captureFeatureView(flag, posthog) - visibilityTrackedRef.current = true - } - } + if (isNull(ref.current) || !trackView) return // eslint-disable-next-line compat/compat const observer = new IntersectionObserver(([entry]) => onIntersect(entry), { @@ -96,11 +83,61 @@ function VisibilityAndClickTracker({ }) observer.observe(ref.current) return () => observer.disconnect() - }, [flag, options, posthog, ref, trackView]) + }, [flag, options, posthog, ref, trackView, onIntersect]) return ( -
+
{children}
) } + +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 cachedOnClick = useCallback(() => { + if (!clickTrackedRef.current && trackInteraction) { + captureFeatureInteraction(flag, posthog) + clickTrackedRef.current = true + } + }, [flag, posthog, trackInteraction]) + + const onIntersect = (entry: IntersectionObserverEntry) => { + if (!visibilityTrackedRef.current && entry.isIntersecting) { + captureFeatureView(flag, posthog) + visibilityTrackedRef.current = true + } + } + + const trackedChildren = Children.map(children, (child: ReactNode) => { + return ( + + {child} + + ) + }) + + return <>{trackedChildren} +} diff --git a/react/src/components/__tests__/PostHogFeature.test.jsx b/react/src/components/__tests__/PostHogFeature.test.jsx index ba6ad0369..8bc27afbc 100644 --- a/react/src/components/__tests__/PostHogFeature.test.jsx +++ b/react/src/components/__tests__/PostHogFeature.test.jsx @@ -1,7 +1,7 @@ import * as React from 'react' -import { useState } from 'react'; +import { useState } from 'react' import { render, screen, fireEvent } from '@testing-library/react' -import { PostHogContext, PostHogProvider } from '../../context' +import { PostHogProvider } from '../../context' import { PostHogFeature } from '../' import '@testing-library/jest-dom' @@ -89,8 +89,34 @@ describe('PostHogFeature component', () => { expect(given.posthog.capture).toHaveBeenCalledTimes(1) }) + it('should track an interaction with each child node of the feature component', () => { + given( + 'render', + () => () => + render( + + +
Hello
+
World!
+
+
+ ) + ) + given.render() + + fireEvent.click(screen.getByTestId('helloDiv')) + fireEvent.click(screen.getByTestId('helloDiv')) + fireEvent.click(screen.getByTestId('worldDiv')) + fireEvent.click(screen.getByTestId('worldDiv')) + fireEvent.click(screen.getByTestId('worldDiv')) + expect(given.posthog.capture).toHaveBeenCalledWith('$feature_interaction', { + feature_flag: 'test', + $set: { '$feature_interaction/test': true }, + }) + expect(given.posthog.capture).toHaveBeenCalledTimes(1) + }) + it('should not fire events when interaction is disabled', () => { - given( 'render', () => () => @@ -114,14 +140,24 @@ describe('PostHogFeature component', () => { }) it('should fire events when interaction is disabled but re-enabled after', () => { - const DynamicUpdateComponent = () => { const [trackInteraction, setTrackInteraction] = useState(false) return ( <> -
{setTrackInteraction(true)}}>Click me
- +
{ + setTrackInteraction(true) + }} + > + Click me +
+
Hello
diff --git a/react/src/utils/type-utils.ts b/react/src/utils/type-utils.ts new file mode 100644 index 000000000..a61194526 --- /dev/null +++ b/react/src/utils/type-utils.ts @@ -0,0 +1,14 @@ +// from a comment on http://dbj.org/dbj/?p=286 +// fails on only one very rare and deliberate custom object: +// let bomb = { toString : undefined, valueOf: function(o) { return "function BOMBA!"; }}; +export const isFunction = function (f: any): f is (...args: any[]) => any { + // eslint-disable-next-line posthog-js/no-direct-function-check + return typeof f === 'function' +} +export const isUndefined = function (x: unknown): x is undefined { + return x === void 0 +} +export const isNull = function (x: unknown): x is null { + // eslint-disable-next-line posthog-js/no-direct-null-check + return x === null +}