From 772b433056ced2190bced4a73a5cd195b8b1f5fd Mon Sep 17 00:00:00 2001 From: Haven Barnes Date: Fri, 8 Nov 2024 11:38:59 -0800 Subject: [PATCH 1/5] fix(flags): support multiple children prop in PostHogFeature --- react/src/components/PostHogFeature.tsx | 45 +++++++++++++---- .../__tests__/PostHogFeature.test.jsx | 48 ++++++++++++++++--- react/src/utils/type-utils.ts | 14 ++++++ 3 files changed, 93 insertions(+), 14 deletions(-) create mode 100644 react/src/utils/type-utils.ts diff --git a/react/src/components/PostHogFeature.tsx b/react/src/components/PostHogFeature.tsx index d0fbf162d..67597c26f 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, { ReactNode, Children, 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} @@ -59,7 +60,6 @@ function VisibilityAndClickTracker({ trackInteraction, trackView, options, - ...props }: { flag: string children: React.ReactNode @@ -80,7 +80,7 @@ function VisibilityAndClickTracker({ }, [flag, posthog, trackInteraction]) useEffect(() => { - if (ref.current === null || !trackView) return + if (isNull(ref.current) || !trackView) return const onIntersect = (entry: IntersectionObserverEntry) => { if (!visibilityTrackedRef.current && entry.isIntersecting) { @@ -99,8 +99,37 @@ function VisibilityAndClickTracker({ }, [flag, options, posthog, ref, trackView]) return ( -
+
{children}
) } + +function VisibilityAndClickTrackers({ + flag, + children, + trackInteraction, + trackView, + options, +}: { + flag: string + children: React.ReactNode + trackInteraction: boolean + trackView: boolean + options?: IntersectionObserverInit +}): JSX.Element { + 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..747eda7a6 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' @@ -34,6 +34,18 @@ describe('PostHogFeature component', () => { ) ) + given( + 'renderMultipleChildren', + () => () => + render( + + +
Hello
+
World!
+
+
+ ) + ) given('posthog', () => ({ isFeatureEnabled: (flag) => !!FEATURE_FLAG_STATUS[flag], getFeatureFlag: (flag) => FEATURE_FLAG_STATUS[flag], @@ -89,8 +101,22 @@ describe('PostHogFeature component', () => { expect(given.posthog.capture).toHaveBeenCalledTimes(1) }) + it('should track an interaction with each child node of the feature component', () => { + given.renderMultipleChildren() + + 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(2) + }) + 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 +} From f0de39efb6ac566a21ca9436d9d78505357403c4 Mon Sep 17 00:00:00 2001 From: Haven Barnes Date: Fri, 8 Nov 2024 12:15:03 -0800 Subject: [PATCH 2/5] cleanup --- react/src/components/PostHogFeature.tsx | 47 ++++++++++--------- .../__tests__/PostHogFeature.test.jsx | 28 +++++------ 2 files changed, 40 insertions(+), 35 deletions(-) diff --git a/react/src/components/PostHogFeature.tsx b/react/src/components/PostHogFeature.tsx index 67597c26f..201c5e0cb 100644 --- a/react/src/components/PostHogFeature.tsx +++ b/react/src/components/PostHogFeature.tsx @@ -57,38 +57,24 @@ function captureFeatureView(flag: string, posthog: PostHog) { function VisibilityAndClickTracker({ flag, children, - trackInteraction, + onIntersect, + onClick, trackView, options, }: { 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 (isNull(ref.current) || !trackView) return - const onIntersect = (entry: IntersectionObserverEntry) => { - if (!visibilityTrackedRef.current && entry.isIntersecting) { - captureFeatureView(flag, posthog) - visibilityTrackedRef.current = true - } - } - // eslint-disable-next-line compat/compat const observer = new IntersectionObserver(([entry]) => onIntersect(entry), { threshold: 0.1, @@ -96,10 +82,10 @@ function VisibilityAndClickTracker({ }) observer.observe(ref.current) return () => observer.disconnect() - }, [flag, options, posthog, ref, trackView]) + }, [flag, options, posthog, ref, trackView, onIntersect]) return ( -
+
{children}
) @@ -118,11 +104,30 @@ function VisibilityAndClickTrackers({ 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 ( diff --git a/react/src/components/__tests__/PostHogFeature.test.jsx b/react/src/components/__tests__/PostHogFeature.test.jsx index 747eda7a6..8bc27afbc 100644 --- a/react/src/components/__tests__/PostHogFeature.test.jsx +++ b/react/src/components/__tests__/PostHogFeature.test.jsx @@ -34,18 +34,6 @@ describe('PostHogFeature component', () => { ) ) - given( - 'renderMultipleChildren', - () => () => - render( - - -
Hello
-
World!
-
-
- ) - ) given('posthog', () => ({ isFeatureEnabled: (flag) => !!FEATURE_FLAG_STATUS[flag], getFeatureFlag: (flag) => FEATURE_FLAG_STATUS[flag], @@ -102,7 +90,19 @@ describe('PostHogFeature component', () => { }) it('should track an interaction with each child node of the feature component', () => { - given.renderMultipleChildren() + given( + 'render', + () => () => + render( + + +
Hello
+
World!
+
+
+ ) + ) + given.render() fireEvent.click(screen.getByTestId('helloDiv')) fireEvent.click(screen.getByTestId('helloDiv')) @@ -113,7 +113,7 @@ describe('PostHogFeature component', () => { feature_flag: 'test', $set: { '$feature_interaction/test': true }, }) - expect(given.posthog.capture).toHaveBeenCalledTimes(2) + expect(given.posthog.capture).toHaveBeenCalledTimes(1) }) it('should not fire events when interaction is disabled', () => { From 3dd05429e4dec80339b767fadda3713d2e407157 Mon Sep 17 00:00:00 2001 From: Haven Barnes Date: Fri, 8 Nov 2024 12:18:10 -0800 Subject: [PATCH 3/5] return prop spread passthrough --- react/src/components/PostHogFeature.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/react/src/components/PostHogFeature.tsx b/react/src/components/PostHogFeature.tsx index 201c5e0cb..0b321c50c 100644 --- a/react/src/components/PostHogFeature.tsx +++ b/react/src/components/PostHogFeature.tsx @@ -61,6 +61,7 @@ function VisibilityAndClickTracker({ onClick, trackView, options, + ...props }: { flag: string children: React.ReactNode @@ -85,7 +86,7 @@ function VisibilityAndClickTracker({ }, [flag, options, posthog, ref, trackView, onIntersect]) return ( -
+
{children}
) @@ -97,6 +98,7 @@ function VisibilityAndClickTrackers({ trackInteraction, trackView, options, + ...props }: { flag: string children: React.ReactNode @@ -130,6 +132,7 @@ function VisibilityAndClickTrackers({ onIntersect={onIntersect} trackView={trackView} options={options} + {...props} > {child} From 143642fec1d903fce9d020a03cfcf6668babd788 Mon Sep 17 00:00:00 2001 From: Haven Barnes Date: Fri, 8 Nov 2024 12:20:48 -0800 Subject: [PATCH 4/5] tweak --- react/src/components/PostHogFeature.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/react/src/components/PostHogFeature.tsx b/react/src/components/PostHogFeature.tsx index 0b321c50c..f68535b71 100644 --- a/react/src/components/PostHogFeature.tsx +++ b/react/src/components/PostHogFeature.tsx @@ -1,5 +1,5 @@ import { useFeatureFlagPayload, useFeatureFlagVariantKey, usePostHog } from '../hooks' -import React, { ReactNode, Children, 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' From 296c348f449ee76ba0a45b4d03917df99ba71348 Mon Sep 17 00:00:00 2001 From: Haven Barnes Date: Fri, 8 Nov 2024 12:30:16 -0800 Subject: [PATCH 5/5] non-optional onClick --- react/src/components/PostHogFeature.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/react/src/components/PostHogFeature.tsx b/react/src/components/PostHogFeature.tsx index f68535b71..fa30a8e30 100644 --- a/react/src/components/PostHogFeature.tsx +++ b/react/src/components/PostHogFeature.tsx @@ -66,7 +66,7 @@ function VisibilityAndClickTracker({ flag: string children: React.ReactNode onIntersect: (entry: IntersectionObserverEntry) => void - onClick?: () => void + onClick: () => void trackView: boolean options?: IntersectionObserverInit }): JSX.Element {