diff --git a/package.json b/package.json index 3348c78f460..38617f26d67 100644 --- a/package.json +++ b/package.json @@ -111,6 +111,7 @@ "@emotion/styled": "11.14.1", "@loadable/component": "5.16.7", "@loadable/server": "5.16.7", + "@optimizely/optimizely-sdk": "5.4.1", "@optimizely/react-sdk": "3.3.1", "aws-embedded-metrics": "4.2.0", "compression": "1.8.1", diff --git a/src/app/components/OptimizelyPageMetrics/index.test.tsx b/src/app/components/OptimizelyPageMetrics/index.test.tsx index a93ceb4d805..053e99f1e24 100644 --- a/src/app/components/OptimizelyPageMetrics/index.test.tsx +++ b/src/app/components/OptimizelyPageMetrics/index.test.tsx @@ -1,4 +1,4 @@ -import { PropsWithChildren } from 'react'; +import { act, PropsWithChildren } from 'react'; import { screen, waitFor } from '@testing-library/react'; import { OptimizelyDecision, @@ -8,6 +8,7 @@ import { import { RequestContextProvider } from '#app/contexts/RequestContext'; import { PageTypes, Services } from '#app/models/types/global'; import { ARTICLE_PAGE, HOME_PAGE } from '#app/routes/utils/pageTypes'; +import { NotificationListener } from '@optimizely/optimizely-sdk'; import { render } from '../react-testing-library-with-providers'; import OptimizelyPageMetrics from '.'; import experimentsForPageMetrics from './experimentsForPageMetrics'; @@ -411,4 +412,106 @@ describe('OptimizelyPageMetrics', () => { }); }); }); + describe('Notification listener', () => { + it('should mount trackers when user is bucketed after load', async () => { + experimentsForPageMetrics.push( + ...[ + { + pageType: ARTICLE_PAGE, + activeExperiments: ['mockExperiment1'], + }, + ], + ); + + let decisionListener: NotificationListener | null = null; + + const mockOptimizely = { + ...optimizely, + decideAll: jest.fn(() => ({ + mockExperiment1: { variationKey: 'off' } as OptimizelyDecision, + })), + notificationCenter: { + addNotificationListener: jest.fn((_, callback) => { + decisionListener = callback; + return 1; + }), + removeNotificationListener: jest.fn(), + clearNotificationListeners: jest.fn(), + clearAllNotificationListeners: jest.fn(), + }, + } satisfies Partial; + + render( + + + , + ); + + await waitFor(() => { + expect( + screen.queryByTestId('page-view-tracking'), + ).not.toBeInTheDocument(); + }); + + act(() => { + decisionListener?.({ + type: 'flag', + decisionInfo: { + flagKey: 'mockExperiment1', + variationKey: 'variation_1', + }, + }); + }); + + await waitFor(() => { + expect(screen.getByTestId('page-view-tracking')).toBeInTheDocument(); + }); + }); + + it('should remove the decision listener on unmount', async () => { + experimentsForPageMetrics.push( + ...[ + { + pageType: ARTICLE_PAGE, + activeExperiments: ['mockExperiment1'], + }, + ], + ); + + const addNotificationListener = jest.fn(() => 1); + const removeNotificationListener = jest.fn(); + + const mockOptimizely = { + ...optimizely, + notificationCenter: { + addNotificationListener, + removeNotificationListener, + clearNotificationListeners: jest.fn(), + clearAllNotificationListeners: jest.fn(), + }, + } satisfies Partial; + + const { unmount } = render( + + + , + ); + + await waitFor(() => { + expect(addNotificationListener).toHaveBeenCalled(); + }); + + unmount(); + + expect(removeNotificationListener).toHaveBeenCalledWith(1); + }); + }); }); diff --git a/src/app/components/OptimizelyPageMetrics/index.tsx b/src/app/components/OptimizelyPageMetrics/index.tsx index 7a547da7fc2..f790ea05b42 100644 --- a/src/app/components/OptimizelyPageMetrics/index.tsx +++ b/src/app/components/OptimizelyPageMetrics/index.tsx @@ -3,6 +3,7 @@ import { OptimizelyContext, OptimizelyDecideOption, } from '@optimizely/react-sdk'; +import { enums } from '@optimizely/optimizely-sdk'; import { RequestContext } from '#contexts/RequestContext'; import PageCompleteTracking from './PageCompleteTracking'; import ScrollDepthTracking from './ScrollDepthTracking'; @@ -16,6 +17,16 @@ type Props = { trackVisit?: boolean; }; +// Shape expected by the Optimizely decision notification listener for decision events +type DecisionListener = { + userId?: string; + type?: string; + decisionInfo?: { + flagKey?: string; + variationKey?: string; + }; +}; + const OptimizelyPageMetrics = ({ trackPageView = false, trackPageDepth = false, @@ -24,43 +35,119 @@ const OptimizelyPageMetrics = ({ }: Props) => { const { optimizely } = useContext(OptimizelyContext); const { isAmp, pageType } = useContext(RequestContext); - const [isInExperiment, setisInExperiment] = useState(false); + const [isInExperiment, setIsInExperiment] = useState(false); const experimentsForPageType = experimentsForPageMetrics.find( entry => entry.pageType === pageType, )?.activeExperiments; - const optimizelyExperimentsEnabled = - experimentsForPageType && !isAmp && !isInExperiment; + const optimizelyExperimentsEnabled = Boolean( + experimentsForPageType?.length && !isAmp, + ); + + // on initial load, check if the user is in any relevant experiment and set state accordingly + useEffect(() => { + if ( + !optimizelyExperimentsEnabled || + !optimizely || + !experimentsForPageType + ) { + setIsInExperiment(false); + return undefined; + } + + let mounted = true; + + optimizely.onReady().then(() => { + if (!mounted) return; + + // disable decision event tracking to avoid sending duplicate events for any experiments that the user is bucketed into on page load, since the notification listener will also trigger for those experiments + const decisions = optimizely.decideAll([ + OptimizelyDecideOption.DISABLE_DECISION_EVENT, + ]); + + const userInAnyExperiment = experimentsForPageType.some( + experimentName => { + const decision = decisions[experimentName]; + return Boolean(decision && decision.variationKey !== 'off'); + }, + ); + + setIsInExperiment(userInAnyExperiment); + }); + + return () => { + mounted = false; + }; + }, [optimizelyExperimentsEnabled, optimizely, experimentsForPageType]); + // Listen for Optimizely decisions after initial load in case the user is bucketed later useEffect(() => { - if (optimizelyExperimentsEnabled) { - optimizely?.onReady().then(() => { - const decisions = optimizely.decideAll([ - OptimizelyDecideOption.DISABLE_DECISION_EVENT, - ]); - const isUserInAnyExperiments = experimentsForPageType.some( - experimentName => { - const decision = decisions[experimentName]; - return decision && decision.variationKey !== 'off'; + if ( + !optimizelyExperimentsEnabled || + !optimizely || + !experimentsForPageType + ) { + setIsInExperiment(false); + return undefined; + } + + let mounted = true; + let notificationId: number | null = null; + + const attachListener = async () => { + await optimizely.onReady(); + if (!mounted) return; + + if ( + optimizely.notificationCenter && + typeof optimizely.notificationCenter.addNotificationListener === + 'function' + ) { + notificationId = optimizely.notificationCenter.addNotificationListener( + enums.NOTIFICATION_TYPES.DECISION, + (listener: DecisionListener) => { + if (!mounted) return; + + const { type, decisionInfo } = listener || {}; + if (type !== 'flag' || !decisionInfo) return; + + const { flagKey, variationKey } = decisionInfo; + + const isRelevantExperiment = + typeof flagKey === 'string' && + experimentsForPageType.includes(flagKey); + + const isUserBucketedIntoExperiment = + typeof variationKey === 'string' && variationKey !== 'off'; + + if (isRelevantExperiment && isUserBucketedIntoExperiment) { + setIsInExperiment(true); + } }, ); + } + }; - if (isUserInAnyExperiments) { - setisInExperiment(true); - } - }); - } - }, [ - optimizelyExperimentsEnabled, - optimizely, - trackPageComplete, - trackPageDepth, - trackPageView, - trackVisit, - experimentsForPageType, - ]); + attachListener(); + + return () => { + mounted = false; + // clean up the notification listener on unmount + if ( + notificationId !== null && + optimizely.notificationCenter && + typeof optimizely.notificationCenter.removeNotificationListener === + 'function' + ) { + optimizely.notificationCenter.removeNotificationListener( + notificationId, + ); + } + }; + }, [optimizelyExperimentsEnabled, optimizely, experimentsForPageType]); + // if the user is not in any relevant experiment, do not render the tracking components to avoid sending unintended events if (!isInExperiment) { return null; } diff --git a/yarn.lock b/yarn.lock index a30454c9a06..703278fc7ed 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4861,7 +4861,7 @@ __metadata: languageName: node linkType: hard -"@optimizely/optimizely-sdk@npm:^5.4.1": +"@optimizely/optimizely-sdk@npm:5.4.1, @optimizely/optimizely-sdk@npm:^5.4.1": version: 5.4.1 resolution: "@optimizely/optimizely-sdk@npm:5.4.1" dependencies: @@ -17587,6 +17587,7 @@ __metadata: "@loadable/component": "npm:5.16.7" "@loadable/server": "npm:5.16.7" "@loadable/webpack-plugin": "npm:5.15.2" + "@optimizely/optimizely-sdk": "npm:5.4.1" "@optimizely/react-sdk": "npm:3.3.1" "@storybook/addon-a11y": "npm:10.3.3" "@storybook/addon-docs": "npm:10.3.3"