diff --git a/src/app/components/OptimizelyPageMetrics/index.test.tsx b/src/app/components/OptimizelyPageMetrics/index.test.tsx index 053e99f1e24..b474f7d0dda 100644 --- a/src/app/components/OptimizelyPageMetrics/index.test.tsx +++ b/src/app/components/OptimizelyPageMetrics/index.test.tsx @@ -1,29 +1,16 @@ import { act, PropsWithChildren } from 'react'; import { screen, waitFor } from '@testing-library/react'; -import { - OptimizelyDecision, - OptimizelyProvider, - ReactSDKClient, -} from '@optimizely/react-sdk'; 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 { + notifyDecision, + resetDecisionStore, +} from '#app/lib/optimizelyDecisionStore'; import { render } from '../react-testing-library-with-providers'; import OptimizelyPageMetrics from '.'; import experimentsForPageMetrics from './experimentsForPageMetrics'; -const optimizely = { - onReady: jest.fn(() => Promise.resolve()), - track: jest.fn(), - setUser: jest.fn(() => Promise.resolve()), - decideAll: jest.fn(() => ({ - mockExperiment1: { variationKey: 'variation_1' } as OptimizelyDecision, - mockExperiment2: { variationKey: 'variation_1' } as OptimizelyDecision, - mockExperimentOff: { variationKey: 'off' } as OptimizelyDecision, - })), -} satisfies Partial; - jest.mock('./PageCompleteTracking', () => () => (
)); @@ -51,7 +38,6 @@ interface Props { pageType: PageTypes; service: Services; isAmp?: boolean; - mockOptimizely?: Partial; } const ContextWrap = ({ @@ -59,7 +45,6 @@ const ContextWrap = ({ children, service, isAmp, - mockOptimizely = optimizely, }: PropsWithChildren) => ( - - {children} - + {children} ); describe('OptimizelyPageMetrics', () => { beforeEach(() => { experimentsForPageMetrics.splice(0, experimentsForPageMetrics.length); + resetDecisionStore(); }); - it('should return null when isAmp is true', async () => { - experimentsForPageMetrics.push( - ...[ - { - pageType: ARTICLE_PAGE, - activeExperiments: ['mockExperiment1', 'mockExperiment2'], - }, - ], - ); + it('should return null when isAmp is true', () => { + experimentsForPageMetrics.push({ + pageType: ARTICLE_PAGE, + activeExperiments: ['mockExperiment1', 'mockExperiment2'], + }); + notifyDecision('mockExperiment1'); render( { /> , ); - await waitFor(() => { - expect( - screen.queryByTestId('page-complete-tracking'), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId('scroll-depth-tracking'), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId('page-view-tracking'), - ).not.toBeInTheDocument(); - expect(screen.queryByTestId('visit-tracking')).not.toBeInTheDocument(); - }); + expect( + screen.queryByTestId('page-complete-tracking'), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('scroll-depth-tracking'), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('page-view-tracking'), + ).not.toBeInTheDocument(); }); - it('should render no tracking components by default when all tracking flags are false', async () => { - experimentsForPageMetrics.push( - ...[ - { - pageType: ARTICLE_PAGE, - activeExperiments: ['mockExperiment1', 'mockExperiment2'], - }, - ], - ); + it('should render no tracking components by default when all tracking flags are false', () => { + experimentsForPageMetrics.push({ + pageType: ARTICLE_PAGE, + activeExperiments: ['mockExperiment1', 'mockExperiment2'], + }); + notifyDecision('mockExperiment1'); render( , ); - await waitFor(() => { - expect( - screen.queryByTestId('page-complete-tracking'), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId('scroll-depth-tracking'), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId('page-view-tracking'), - ).not.toBeInTheDocument(); - expect(screen.queryByTestId('visit-tracking')).not.toBeInTheDocument(); - }); + expect( + screen.queryByTestId('page-complete-tracking'), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('scroll-depth-tracking'), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('page-view-tracking'), + ).not.toBeInTheDocument(); }); - it('should render PageCompleteTracking when trackPageComplete is true', async () => { - experimentsForPageMetrics.push( - ...[ - { - pageType: ARTICLE_PAGE, - activeExperiments: ['mockExperiment1', 'mockExperiment2'], - }, - ], - ); + it('should render PageCompleteTracking when trackPageComplete is true', () => { + experimentsForPageMetrics.push({ + pageType: ARTICLE_PAGE, + activeExperiments: ['mockExperiment1', 'mockExperiment2'], + }); + notifyDecision('mockExperiment1'); render( , ); - await waitFor(() => { - expect(screen.getByTestId('page-complete-tracking')).toBeInTheDocument(); - }); + expect(screen.getByTestId('page-complete-tracking')).toBeInTheDocument(); }); - it('should render ScrollDepthTracking when trackPageDepth is true', async () => { - experimentsForPageMetrics.push( - ...[ - { - pageType: ARTICLE_PAGE, - activeExperiments: ['mockExperiment1', 'mockExperiment2'], - }, - ], - ); + it('should render ScrollDepthTracking when trackPageDepth is true', () => { + experimentsForPageMetrics.push({ + pageType: ARTICLE_PAGE, + activeExperiments: ['mockExperiment1', 'mockExperiment2'], + }); + notifyDecision('mockExperiment1'); render( , ); - await waitFor(() => { - expect(screen.getByTestId('scroll-depth-tracking')).toBeInTheDocument(); - }); + expect(screen.getByTestId('scroll-depth-tracking')).toBeInTheDocument(); }); - it('should render PageViewTracking when trackPageView is true', async () => { - experimentsForPageMetrics.push( - ...[ - { - pageType: ARTICLE_PAGE, - activeExperiments: ['mockExperiment1', 'mockExperiment2'], - }, - ], - ); + it('should render PageViewTracking when trackPageView is true', () => { + experimentsForPageMetrics.push({ + pageType: ARTICLE_PAGE, + activeExperiments: ['mockExperiment1', 'mockExperiment2'], + }); + notifyDecision('mockExperiment1'); render( , ); - await waitFor(() => { - expect(screen.getByTestId('page-view-tracking')).toBeInTheDocument(); - }); + expect(screen.getByTestId('page-view-tracking')).toBeInTheDocument(); }); - it('should render all tracking components when all flags are true', async () => { - experimentsForPageMetrics.push( - ...[ - { - pageType: ARTICLE_PAGE, - activeExperiments: ['mockExperiment1', 'mockExperiment2'], - }, - ], - ); + it('should render all tracking components when all flags are true', () => { + experimentsForPageMetrics.push({ + pageType: ARTICLE_PAGE, + activeExperiments: ['mockExperiment1', 'mockExperiment2'], + }); + notifyDecision('mockExperiment1'); render( { /> , ); - await waitFor(() => { - expect(screen.getByTestId('page-complete-tracking')).toBeInTheDocument(); - expect(screen.getByTestId('scroll-depth-tracking')).toBeInTheDocument(); - expect(screen.getByTestId('page-view-tracking')).toBeInTheDocument(); - expect(screen.getByTestId('page-view-tracking')).toHaveAttribute( - 'data-track-visit', - 'true', - ); - }); + expect(screen.getByTestId('page-complete-tracking')).toBeInTheDocument(); + expect(screen.getByTestId('scroll-depth-tracking')).toBeInTheDocument(); + expect(screen.getByTestId('page-view-tracking')).toBeInTheDocument(); + expect(screen.getByTestId('page-view-tracking')).toHaveAttribute( + 'data-track-visit', + 'true', + ); }); - it('should return null when there are no experiments running', async () => { - experimentsForPageMetrics.push(...[]); + it('should return null when there are no experiments running', () => { render( , ); - await waitFor(() => { - expect( - screen.queryByTestId('page-complete-tracking'), - ).not.toBeInTheDocument(); - }); + expect( + screen.queryByTestId('page-complete-tracking'), + ).not.toBeInTheDocument(); }); - it('should return null when a user is no experiments', async () => { - experimentsForPageMetrics.push( - ...[ - { - pageType: ARTICLE_PAGE, - activeExperiments: ['mockExperimentOff'], - }, - ], - ); + it('should return null when a user is not activated in any experiment', () => { + experimentsForPageMetrics.push({ + pageType: ARTICLE_PAGE, + activeExperiments: ['mockExperiment1'], + }); render( , ); - await waitFor(() => { - expect( - screen.queryByTestId('page-complete-tracking'), - ).not.toBeInTheDocument(); - }); + expect( + screen.queryByTestId('page-complete-tracking'), + ).not.toBeInTheDocument(); }); - it('should return null when pageType does not match', async () => { - experimentsForPageMetrics.push( - ...[ - { - pageType: HOME_PAGE, - activeExperiments: ['mockExperiment1', 'mockExperiment2'], - }, - ], - ); + it('should return null when pageType does not match', () => { + experimentsForPageMetrics.push({ + pageType: HOME_PAGE, + activeExperiments: ['mockExperiment1', 'mockExperiment2'], + }); + notifyDecision('mockExperiment1'); render( , ); - await waitFor(() => { - expect( - screen.queryByTestId('page-complete-tracking'), - ).not.toBeInTheDocument(); - }); + expect( + screen.queryByTestId('page-complete-tracking'), + ).not.toBeInTheDocument(); }); - it('should null when experiment names do not match Optimizely', async () => { - experimentsForPageMetrics.push( - ...[ - { - pageType: ARTICLE_PAGE, - activeExperiments: ['invalidExperiment'], - }, - ], - ); - render( - - - , - ); - await waitFor(() => { - expect( - screen.queryByTestId('page-complete-tracking'), - ).not.toBeInTheDocument(); + it('should return null when experiment names do not match activated experiments', () => { + experimentsForPageMetrics.push({ + pageType: ARTICLE_PAGE, + activeExperiments: ['invalidExperiment'], }); - }); - - it('should call decideAll with argument to disable decision impression activation event', async () => { - experimentsForPageMetrics.push( - ...[ - { - pageType: ARTICLE_PAGE, - activeExperiments: ['mockExperiment1', 'mockExperiment2'], - }, - ], - ); + notifyDecision('someOtherExperiment'); render( { /> , ); - await waitFor(() => { - expect(optimizely.decideAll).toHaveBeenCalledWith([ - 'DISABLE_DECISION_EVENT', - ]); - }); + expect( + screen.queryByTestId('page-complete-tracking'), + ).not.toBeInTheDocument(); }); describe('Multiple experiments on different page types', () => { - it('should render correctly when a user is in an experiment on the current page type', async () => { + it('should render correctly when a user is in an experiment on the current page type', () => { experimentsForPageMetrics.push( - ...[ - { - pageType: ARTICLE_PAGE, - activeExperiments: ['mockExperiment1'], - }, - { - pageType: HOME_PAGE, - activeExperiments: ['mockExperimentOff'], - }, - ], + { + pageType: ARTICLE_PAGE, + activeExperiments: ['mockExperiment1'], + }, + { + pageType: HOME_PAGE, + activeExperiments: ['mockExperiment2'], + }, ); + notifyDecision('mockExperiment1'); render( { /> , ); - await waitFor(() => { - expect( - screen.getByTestId('page-complete-tracking'), - ).toBeInTheDocument(); - expect(screen.getByTestId('scroll-depth-tracking')).toBeInTheDocument(); - expect(screen.getByTestId('page-view-tracking')).toBeInTheDocument(); - expect(screen.queryByTestId('visit-tracking')).not.toBeInTheDocument(); - expect(screen.getByTestId('page-view-tracking')).toHaveAttribute( - 'data-track-visit', - 'true', - ); - }); + expect( + screen.getByTestId('page-complete-tracking'), + ).toBeInTheDocument(); + expect(screen.getByTestId('scroll-depth-tracking')).toBeInTheDocument(); + expect(screen.getByTestId('page-view-tracking')).toBeInTheDocument(); + expect(screen.getByTestId('page-view-tracking')).toHaveAttribute( + 'data-track-visit', + 'true', + ); }); - it('should return null when a user is not in an experiment on the current page type', async () => { + it('should return null when a user is not in an experiment on the current page type', () => { experimentsForPageMetrics.push( - ...[ - { - pageType: ARTICLE_PAGE, - activeExperiments: ['mockExperimentOff'], - }, - { - pageType: HOME_PAGE, - activeExperiments: ['mockExperiment2'], - }, - ], + { + pageType: ARTICLE_PAGE, + activeExperiments: ['mockExperiment1'], + }, + { + pageType: HOME_PAGE, + activeExperiments: ['mockExperiment2'], + }, ); + notifyDecision('mockExperiment2'); render( { /> , ); - await waitFor(() => { - expect( - screen.queryByTestId('page-complete-tracking'), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId('scroll-depth-tracking'), - ).not.toBeInTheDocument(); - expect( - screen.queryByTestId('page-view-tracking'), - ).not.toBeInTheDocument(); - expect(screen.queryByTestId('visit-tracking')).not.toBeInTheDocument(); - }); + expect( + screen.queryByTestId('page-complete-tracking'), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('scroll-depth-tracking'), + ).not.toBeInTheDocument(); + expect( + screen.queryByTestId('page-view-tracking'), + ).not.toBeInTheDocument(); }); }); - 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; + describe('Decision store updates', () => { + it('should mount trackers when a decision is notified after initial render', async () => { + experimentsForPageMetrics.push({ + pageType: ARTICLE_PAGE, + activeExperiments: ['mockExperiment1'], + }); render( - + , ); - - await waitFor(() => { - expect( - screen.queryByTestId('page-view-tracking'), - ).not.toBeInTheDocument(); - }); + expect( + screen.queryByTestId('page-view-tracking'), + ).not.toBeInTheDocument(); act(() => { - decisionListener?.({ - type: 'flag', - decisionInfo: { - flagKey: 'mockExperiment1', - variationKey: 'variation_1', - }, - }); + notifyDecision('mockExperiment1'); }); await waitFor(() => { @@ -472,46 +333,26 @@ describe('OptimizelyPageMetrics', () => { }); }); - 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( - + it('should not mount trackers when an irrelevant decision is notified', async () => { + experimentsForPageMetrics.push({ + pageType: ARTICLE_PAGE, + activeExperiments: ['mockExperiment1'], + }); + render( + , ); - await waitFor(() => { - expect(addNotificationListener).toHaveBeenCalled(); + act(() => { + notifyDecision('unrelatedExperiment'); }); - unmount(); - - expect(removeNotificationListener).toHaveBeenCalledWith(1); + await waitFor(() => { + expect( + screen.queryByTestId('page-view-tracking'), + ).not.toBeInTheDocument(); + }); }); }); }); diff --git a/src/app/components/OptimizelyPageMetrics/index.tsx b/src/app/components/OptimizelyPageMetrics/index.tsx index f790ea05b42..6d192475099 100644 --- a/src/app/components/OptimizelyPageMetrics/index.tsx +++ b/src/app/components/OptimizelyPageMetrics/index.tsx @@ -1,10 +1,6 @@ -import { useState, useContext, useEffect } from 'react'; -import { - OptimizelyContext, - OptimizelyDecideOption, -} from '@optimizely/react-sdk'; -import { enums } from '@optimizely/optimizely-sdk'; +import { useContext } from 'react'; import { RequestContext } from '#contexts/RequestContext'; +import { useActivatedExperiments } from '#app/lib/optimizelyDecisionStore'; import PageCompleteTracking from './PageCompleteTracking'; import ScrollDepthTracking from './ScrollDepthTracking'; import PageViewTracking from './PageViewTracking'; @@ -17,25 +13,14 @@ 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, trackPageComplete = false, trackVisit = false, }: Props) => { - const { optimizely } = useContext(OptimizelyContext); const { isAmp, pageType } = useContext(RequestContext); - const [isInExperiment, setIsInExperiment] = useState(false); + const activatedExperiments = useActivatedExperiments(); const experimentsForPageType = experimentsForPageMetrics.find( entry => entry.pageType === pageType, @@ -45,116 +30,16 @@ const OptimizelyPageMetrics = ({ 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 || - !experimentsForPageType - ) { - setIsInExperiment(false); - return undefined; - } - - let mounted = true; - let notificationId: number | null = null; + const isInExperiment = + optimizelyExperimentsEnabled && + Boolean( + experimentsForPageType?.some(name => activatedExperiments.has(name)), + ); - 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); - } - }, - ); - } - }; - - 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; } - // for page views per visit, always enable both trackPageView and trackVisit - // visit tracking runs inside the page view tracker to keep ordering and avoid duplicates - return ( <> {trackPageComplete && } diff --git a/src/app/legacy/containers/PageHandlers/withOptimizelyProvider/index.tsx b/src/app/legacy/containers/PageHandlers/withOptimizelyProvider/index.tsx index 7bfcb7e2baa..26525fc8e39 100644 --- a/src/app/legacy/containers/PageHandlers/withOptimizelyProvider/index.tsx +++ b/src/app/legacy/containers/PageHandlers/withOptimizelyProvider/index.tsx @@ -4,11 +4,13 @@ import { OptimizelyProvider, setLogger, } from '@optimizely/react-sdk'; +import { enums, ListenerPayload } from '@optimizely/optimizely-sdk'; import Cookie from 'js-cookie'; import isLive from '#lib/utilities/isLive'; import onClient from '#lib/utilities/onClient'; import { getEnvConfig } from '#app/lib/utilities/getEnvConfig'; import isOperaProxy from '#app/lib/utilities/isOperaProxy'; +import { notifyDecision } from '#app/lib/optimizelyDecisionStore'; import { RequestContext } from '#contexts/RequestContext'; import { ServiceContext } from '#contexts/ServiceContext'; import isCypress from './isCypress'; @@ -34,6 +36,26 @@ const optimizely = createInstance({ eventFlushInterval: 1000, }); +optimizely.notificationCenter.addNotificationListener( + enums.NOTIFICATION_TYPES.DECISION, + ( + notification: ListenerPayload & { + decisionInfo: { + flagKey: string; + variationKey: string; + decisionEventDispatched: boolean; + }; + }, + ) => { + const { flagKey, variationKey, decisionEventDispatched } = + notification.decisionInfo; + + if (decisionEventDispatched && variationKey !== 'off') { + notifyDecision(flagKey); + } + }, +); + const withOptimizelyProvider = (Component: ComponentType) => { return props => { if (disableOptimizely) return ; diff --git a/src/app/lib/optimizelyDecisionStore.test.ts b/src/app/lib/optimizelyDecisionStore.test.ts new file mode 100644 index 00000000000..0ef42c2f14f --- /dev/null +++ b/src/app/lib/optimizelyDecisionStore.test.ts @@ -0,0 +1,66 @@ +import { + subscribe, + getSnapshot, + notifyDecision, + resetDecisionStore, +} from './optimizelyDecisionStore'; + +describe('optimizelyDecisionStore', () => { + beforeEach(() => { + resetDecisionStore(); + }); + + it('should start with an empty snapshot', () => { + expect(getSnapshot().size).toBe(0); + }); + + it('should add a flag key to the snapshot on notifyDecision', () => { + notifyDecision('experiment_1'); + expect(getSnapshot().has('experiment_1')).toBe(true); + }); + + it('should accumulate multiple flag keys', () => { + notifyDecision('experiment_1'); + notifyDecision('experiment_2'); + const snap = getSnapshot(); + expect(snap.has('experiment_1')).toBe(true); + expect(snap.has('experiment_2')).toBe(true); + expect(snap.size).toBe(2); + }); + + it('should notify subscribers when a new decision is added', () => { + const callback = jest.fn(); + subscribe(callback); + notifyDecision('experiment_1'); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should not notify subscribers for duplicate decisions', () => { + const callback = jest.fn(); + subscribe(callback); + notifyDecision('experiment_1'); + notifyDecision('experiment_1'); + expect(callback).toHaveBeenCalledTimes(1); + }); + + it('should unsubscribe when the returned function is called', () => { + const callback = jest.fn(); + const unsubscribe = subscribe(callback); + unsubscribe(); + notifyDecision('experiment_1'); + expect(callback).not.toHaveBeenCalled(); + }); + + it('should return a new snapshot reference after each decision', () => { + const first = getSnapshot(); + notifyDecision('experiment_1'); + const second = getSnapshot(); + expect(first).not.toBe(second); + }); + + it('should reset the store state', () => { + notifyDecision('experiment_1'); + resetDecisionStore(); + expect(getSnapshot().size).toBe(0); + }); +}); diff --git a/src/app/lib/optimizelyDecisionStore.ts b/src/app/lib/optimizelyDecisionStore.ts new file mode 100644 index 00000000000..9a68683e94b --- /dev/null +++ b/src/app/lib/optimizelyDecisionStore.ts @@ -0,0 +1,37 @@ +import { useSyncExternalStore } from 'react'; + +const activatedExperiments = new Set(); +let snapshot: ReadonlySet = new Set(); +const subscribers = new Set<() => void>(); + +const subscribe = (callback: () => void) => { + subscribers.add(callback); + return () => { + subscribers.delete(callback); + }; +}; + +const getSnapshot = (): ReadonlySet => snapshot; + +const notifyDecision = (flagKey: string) => { + if (activatedExperiments.has(flagKey)) return; + activatedExperiments.add(flagKey); + snapshot = new Set(activatedExperiments); + subscribers.forEach(cb => cb()); +}; + +const resetDecisionStore = () => { + activatedExperiments.clear(); + snapshot = new Set(); +}; + +const useActivatedExperiments = () => + useSyncExternalStore(subscribe, getSnapshot, getSnapshot); + +export { + subscribe, + getSnapshot, + notifyDecision, + resetDecisionStore, + useActivatedExperiments, +};