From af2d86c81cc312cfd1a268eb7a96df2a8f4832a6 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Fri, 5 Jul 2024 22:49:50 +0600 Subject: [PATCH 1/6] [FSSDK-9871] Hook for track events --- src/hooks.ts | 43 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 43 insertions(+) diff --git a/src/hooks.ts b/src/hooks.ts index 038f3c5..32826e9 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -87,6 +87,8 @@ interface UseDecision { ]; } +type UseTrackEvents = () => [null, false, false] | [ReactSDKClient['track'], ClientReady, DidTimeout]; + interface DecisionInputs { entityKey: string; overrideUserId?: string; @@ -500,3 +502,44 @@ export const useDecision: UseDecision = (flagKey, options = {}, overrides = {}) return [state.decision, state.clientReady, state.didTimeout]; }; + + +export const useTrackEvents: UseTrackEvents = () => { + const { optimizely, isServerSide, timeout } = useContext(OptimizelyContext); + + if (!optimizely) { + hooksLogger.error(`Unable to track events. optimizely prop must be supplied via a parent `); + return [null, false, false]; + } + const isClientReady = isServerSide || optimizely.isReady(); + + const [state, setState] = useState<{ + track: ReactSDKClient['track']; + clientReady: boolean; + didTimeout: DidTimeout; + }>(() => { + return { + track: optimizely.track, + clientReady: isClientReady, + didTimeout: false, + }; + }); + + useEffect(() => { + // Subscribe to initialization promise only + // 1. When client is using Sdk Key, which means the initialization will be asynchronous + // and we need to wait for the promise and update decision. + // 2. When client is using datafile only but client is not ready yet which means user + // was provided as a promise and we need to subscribe and wait for user to become available. + if (optimizely.getIsUsingSdkKey() || !isClientReady) { + subscribeToInitialization(optimizely, timeout, initState => { + setState({ + track: optimizely.track, + ...initState, + }); + }); + } + }, []); + + return [state.track, state.clientReady, state.didTimeout]; +}; From d1e5ec2a70483add4be5922384cdf01c937f6ba0 Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Mon, 8 Jul 2024 18:06:13 +0600 Subject: [PATCH 2/6] [FSSDK-9871] useTrackEvents test addition --- src/hooks.spec.tsx | 29 +++++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/src/hooks.spec.tsx b/src/hooks.spec.tsx index 3615460..cc2dd6e 100644 --- a/src/hooks.spec.tsx +++ b/src/hooks.spec.tsx @@ -18,12 +18,12 @@ import * as React from 'react'; import { act } from 'react-dom/test-utils'; -import { render, screen, waitFor } from '@testing-library/react'; +import { render, renderHook, screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom/extend-expect'; import { OptimizelyProvider } from './Provider'; import { OnReadyResult, ReactSDKClient, VariableValuesObject } from './client'; -import { useExperiment, useFeature, useDecision } from './hooks'; +import { useExperiment, useFeature, useDecision, useTrackEvents } from './hooks'; import { OptimizelyDecision } from './utils'; const defaultDecision: OptimizelyDecision = { @@ -110,6 +110,7 @@ describe('hooks', () => { forcedVariationUpdateCallbacks = []; decideMock = jest.fn(); setForcedDecisionMock = jest.fn(); + // subscribeToInitialization = jest.fn(); optimizelyMock = ({ activate: activateMock, @@ -141,6 +142,7 @@ describe('hooks', () => { getForcedVariations: jest.fn().mockReturnValue({}), decide: decideMock, setForcedDecision: setForcedDecisionMock, + track: jest.fn(), } as unknown) as ReactSDKClient; mockLog = jest.fn(); @@ -1048,4 +1050,27 @@ describe('hooks', () => { await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('false|{}|true|false')); }); }); + describe('useTrackEvents', () => { + it('returns null and false states when optimizely is not provided', () => { + const wrapper = ({ children }: { children: React.ReactNode }): React.ReactElement => { + //@ts-ignore + return {children}; + }; + const { result } = renderHook(() => useTrackEvents(), { wrapper }); + expect(result.current).toEqual([null, false, false]); + }); + + it('returns track method along with clientReady while optimizely instance is provided', async () => { + const wrapper = ({ children }: { children: React.ReactNode }): React.ReactElement => ( + + {children} + + ); + + const { result } = renderHook(() => useTrackEvents(), { wrapper }); + + expect(result.current[1]).toBe(true); + expect(result.current[2]).toBe(false); + }); + }); }); From 17b9ce1dab728f1b81950375344c55bea6b35f7c Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Mon, 8 Jul 2024 21:09:06 +0600 Subject: [PATCH 3/6] [FSSDK-9871] useTrackEvents hook + test improvement --- src/hooks.spec.tsx | 46 +++++++++++++++++++++++++++++++++++----------- src/hooks.ts | 36 +++++++++++++++++++++++------------- src/index.ts | 2 +- 3 files changed, 59 insertions(+), 25 deletions(-) diff --git a/src/hooks.spec.tsx b/src/hooks.spec.tsx index cc2dd6e..963d94b 100644 --- a/src/hooks.spec.tsx +++ b/src/hooks.spec.tsx @@ -23,9 +23,8 @@ import '@testing-library/jest-dom/extend-expect'; import { OptimizelyProvider } from './Provider'; import { OnReadyResult, ReactSDKClient, VariableValuesObject } from './client'; -import { useExperiment, useFeature, useDecision, useTrackEvents } from './hooks'; +import { useExperiment, useFeature, useDecision, useTrackEvents, hooksLogger } from './hooks'; import { OptimizelyDecision } from './utils'; - const defaultDecision: OptimizelyDecision = { enabled: false, variables: {}, @@ -80,7 +79,7 @@ describe('hooks', () => { let forcedVariationUpdateCallbacks: Array<() => void>; let decideMock: jest.Mock; let setForcedDecisionMock: jest.Mock; - + let hooksLoggerErrorSpy: jest.SpyInstance; const REJECTION_REASON = 'A rejection reason you should never see in the test runner'; beforeEach(() => { @@ -99,7 +98,6 @@ describe('hooks', () => { ); }, timeout || mockDelay); }); - activateMock = jest.fn(); isFeatureEnabledMock = jest.fn(); featureVariables = mockFeatureVariables; @@ -110,8 +108,7 @@ describe('hooks', () => { forcedVariationUpdateCallbacks = []; decideMock = jest.fn(); setForcedDecisionMock = jest.fn(); - // subscribeToInitialization = jest.fn(); - + hooksLoggerErrorSpy = jest.spyOn(hooksLogger, 'error'); optimizelyMock = ({ activate: activateMock, onReady: jest.fn().mockImplementation(config => getOnReadyPromise(config || {})), @@ -170,6 +167,7 @@ describe('hooks', () => { res => res.dataReadyPromise, err => null ); + hooksLoggerErrorSpy.mockReset(); }); describe('useExperiment', () => { @@ -1051,16 +1049,18 @@ describe('hooks', () => { }); }); describe('useTrackEvents', () => { - it('returns null and false states when optimizely is not provided', () => { + it('returns track method and false states when optimizely is not provided', () => { const wrapper = ({ children }: { children: React.ReactNode }): React.ReactElement => { //@ts-ignore return {children}; }; const { result } = renderHook(() => useTrackEvents(), { wrapper }); - expect(result.current).toEqual([null, false, false]); + expect(result.current[0]).toBeInstanceOf(Function); + expect(result.current[1]).toBe(false); + expect(result.current[2]).toBe(false); }); - it('returns track method along with clientReady while optimizely instance is provided', async () => { + it('returns track method along with clientReady and didTimeout state when optimizely instance is provided', () => { const wrapper = ({ children }: { children: React.ReactNode }): React.ReactElement => ( {children} @@ -1068,9 +1068,33 @@ describe('hooks', () => { ); const { result } = renderHook(() => useTrackEvents(), { wrapper }); + expect(result.current[0]).toBeInstanceOf(Function); + expect(typeof result.current[1]).toBe('boolean'); + expect(typeof result.current[2]).toBe('boolean'); + }); - expect(result.current[1]).toBe(true); - expect(result.current[2]).toBe(false); + it('Log error when track method is called and optimizely instance is not provided', () => { + const wrapper = ({ children }: { children: React.ReactNode }): React.ReactElement => { + //@ts-ignore + return {children}; + }; + const { result } = renderHook(() => useTrackEvents(), { wrapper }); + result.current[0]('eventKey'); + expect(hooksLogger.error).toHaveBeenCalledTimes(1); + }); + + it('Log error when track method is called and client is not ready', () => { + optimizelyMock.isReady = jest.fn().mockReturnValue(false); + + const wrapper = ({ children }: { children: React.ReactNode }): React.ReactElement => ( + + {children} + + ); + + const { result } = renderHook(() => useTrackEvents(), { wrapper }); + result.current[0]('eventKey'); + expect(hooksLogger.error).toHaveBeenCalledTimes(1); }); }); }); diff --git a/src/hooks.ts b/src/hooks.ts index 32826e9..d4f5c60 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -23,7 +23,7 @@ import { notifier } from './notifier'; import { OptimizelyContext } from './Context'; import { areAttributesEqual, OptimizelyDecision, createFailedDecision } from './utils'; -const hooksLogger = getLogger('ReactSDK'); +export const hooksLogger = getLogger('ReactSDK'); enum HookType { EXPERIMENT = 'Experiment', @@ -87,7 +87,9 @@ interface UseDecision { ]; } -type UseTrackEvents = () => [null, false, false] | [ReactSDKClient['track'], ClientReady, DidTimeout]; +interface UseTrackEvents { + (): [(...args: Parameters) => void, boolean, boolean]; +} interface DecisionInputs { entityKey: string; @@ -503,23 +505,34 @@ export const useDecision: UseDecision = (flagKey, options = {}, overrides = {}) return [state.decision, state.clientReady, state.didTimeout]; }; - export const useTrackEvents: UseTrackEvents = () => { const { optimizely, isServerSide, timeout } = useContext(OptimizelyContext); + const isClientReady = !!(isServerSide || optimizely?.isReady()); + + const track = useCallback( + (...rest: Parameters): void => { + if (!optimizely) { + hooksLogger.error(`Unable to track events. optimizely prop must be supplied via a parent `); + return; + } + if (!isClientReady) { + hooksLogger.error(`Unable to track events. Optimizely client is not ready yet.`); + return; + } + optimizely.track(...rest); + }, + [optimizely, isClientReady] + ); if (!optimizely) { - hooksLogger.error(`Unable to track events. optimizely prop must be supplied via a parent `); - return [null, false, false]; + return [track, false, false]; } - const isClientReady = isServerSide || optimizely.isReady(); const [state, setState] = useState<{ - track: ReactSDKClient['track']; clientReady: boolean; didTimeout: DidTimeout; }>(() => { return { - track: optimizely.track, clientReady: isClientReady, didTimeout: false, }; @@ -533,13 +546,10 @@ export const useTrackEvents: UseTrackEvents = () => { // was provided as a promise and we need to subscribe and wait for user to become available. if (optimizely.getIsUsingSdkKey() || !isClientReady) { subscribeToInitialization(optimizely, timeout, initState => { - setState({ - track: optimizely.track, - ...initState, - }); + setState(initState); }); } }, []); - return [state.track, state.clientReady, state.didTimeout]; + return [track, state.clientReady, state.didTimeout]; }; diff --git a/src/index.ts b/src/index.ts index 471ece6..c979684 100644 --- a/src/index.ts +++ b/src/index.ts @@ -17,7 +17,7 @@ export { OptimizelyContext, OptimizelyContextConsumer, OptimizelyContextProvider } from './Context'; export { OptimizelyProvider } from './Provider'; export { OptimizelyFeature } from './Feature'; -export { useFeature, useExperiment, useDecision } from './hooks'; +export { useFeature, useExperiment, useDecision, useTrackEvents } from './hooks'; export { withOptimizely, WithOptimizelyProps, WithoutOptimizelyProps } from './withOptimizely'; export { OptimizelyExperiment } from './Experiment'; export { OptimizelyVariation } from './Variation'; From 330be485c215aae910b7ccce33d687c2670de40e Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Mon, 8 Jul 2024 21:18:57 +0600 Subject: [PATCH 4/6] [FSSDK-9871] readme update --- README.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/README.md b/README.md index 7b7ec29..11386ae 100644 --- a/README.md +++ b/README.md @@ -328,6 +328,23 @@ function MyComponent() { ``` ### Tracking +Use the built-in `useTrackEvents` hook to access the `track` method of optimizely instance + +```jsx +function SignupButton() { + const [track, clientReady, didTimeout] = useTrackEvents() + + const handleClick = () => { + if(clientReady) { + track('signup-clicked') + } + } + + return ( + + ) +} +``` Use the `withOptimizely` HoC for tracking. From cab5157b0fd23391e8c2f4d290cfbf682dcade7e Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Mon, 8 Jul 2024 21:26:06 +0600 Subject: [PATCH 5/6] [FSSDK-9871] readme fix --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 11386ae..242c9f2 100644 --- a/README.md +++ b/README.md @@ -346,7 +346,7 @@ function SignupButton() { } ``` -Use the `withOptimizely` HoC for tracking. +Or you can use the `withOptimizely` HoC. ```jsx import { withOptimizely } from '@optimizely/react-sdk'; From e41dd668682f5568802d20dce43a81c032336a0e Mon Sep 17 00:00:00 2001 From: Md Junaed Hossain <169046794+junaed-optimizely@users.noreply.github.com> Date: Tue, 9 Jul 2024 18:00:58 +0600 Subject: [PATCH 6/6] [FSSDK-9871] singular hook name and interface update + copyright update --- README.md | 6 ++++-- src/hooks.spec.tsx | 14 +++++++------- src/hooks.ts | 4 ++-- src/index.ts | 4 ++-- 4 files changed, 15 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index 242c9f2..d827a39 100644 --- a/README.md +++ b/README.md @@ -328,11 +328,13 @@ function MyComponent() { ``` ### Tracking -Use the built-in `useTrackEvents` hook to access the `track` method of optimizely instance +Use the built-in `useTrackEvent` hook to access the `track` method of optimizely instance ```jsx +import { useTrackEvent } from '@optimizely/react-sdk'; + function SignupButton() { - const [track, clientReady, didTimeout] = useTrackEvents() + const [track, clientReady, didTimeout] = useTrackEvent() const handleClick = () => { if(clientReady) { diff --git a/src/hooks.spec.tsx b/src/hooks.spec.tsx index 963d94b..8dda8d5 100644 --- a/src/hooks.spec.tsx +++ b/src/hooks.spec.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2022, 2023 Optimizely + * Copyright 2022, 2023, 2024 Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -23,7 +23,7 @@ import '@testing-library/jest-dom/extend-expect'; import { OptimizelyProvider } from './Provider'; import { OnReadyResult, ReactSDKClient, VariableValuesObject } from './client'; -import { useExperiment, useFeature, useDecision, useTrackEvents, hooksLogger } from './hooks'; +import { useExperiment, useFeature, useDecision, useTrackEvent, hooksLogger } from './hooks'; import { OptimizelyDecision } from './utils'; const defaultDecision: OptimizelyDecision = { enabled: false, @@ -1048,13 +1048,13 @@ describe('hooks', () => { await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('false|{}|true|false')); }); }); - describe('useTrackEvents', () => { + describe('useTrackEvent', () => { it('returns track method and false states when optimizely is not provided', () => { const wrapper = ({ children }: { children: React.ReactNode }): React.ReactElement => { //@ts-ignore return {children}; }; - const { result } = renderHook(() => useTrackEvents(), { wrapper }); + const { result } = renderHook(() => useTrackEvent(), { wrapper }); expect(result.current[0]).toBeInstanceOf(Function); expect(result.current[1]).toBe(false); expect(result.current[2]).toBe(false); @@ -1067,7 +1067,7 @@ describe('hooks', () => { ); - const { result } = renderHook(() => useTrackEvents(), { wrapper }); + const { result } = renderHook(() => useTrackEvent(), { wrapper }); expect(result.current[0]).toBeInstanceOf(Function); expect(typeof result.current[1]).toBe('boolean'); expect(typeof result.current[2]).toBe('boolean'); @@ -1078,7 +1078,7 @@ describe('hooks', () => { //@ts-ignore return {children}; }; - const { result } = renderHook(() => useTrackEvents(), { wrapper }); + const { result } = renderHook(() => useTrackEvent(), { wrapper }); result.current[0]('eventKey'); expect(hooksLogger.error).toHaveBeenCalledTimes(1); }); @@ -1092,7 +1092,7 @@ describe('hooks', () => { ); - const { result } = renderHook(() => useTrackEvents(), { wrapper }); + const { result } = renderHook(() => useTrackEvent(), { wrapper }); result.current[0]('eventKey'); expect(hooksLogger.error).toHaveBeenCalledTimes(1); }); diff --git a/src/hooks.ts b/src/hooks.ts index d4f5c60..f78fd14 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -87,7 +87,7 @@ interface UseDecision { ]; } -interface UseTrackEvents { +interface UseTrackEvent { (): [(...args: Parameters) => void, boolean, boolean]; } @@ -505,7 +505,7 @@ export const useDecision: UseDecision = (flagKey, options = {}, overrides = {}) return [state.decision, state.clientReady, state.didTimeout]; }; -export const useTrackEvents: UseTrackEvents = () => { +export const useTrackEvent: UseTrackEvent = () => { const { optimizely, isServerSide, timeout } = useContext(OptimizelyContext); const isClientReady = !!(isServerSide || optimizely?.isReady()); diff --git a/src/index.ts b/src/index.ts index c979684..63634d9 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,5 +1,5 @@ /** - * Copyright 2018-2019, 2023 Optimizely + * Copyright 2018-2019, 2023, 2024 Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,7 +17,7 @@ export { OptimizelyContext, OptimizelyContextConsumer, OptimizelyContextProvider } from './Context'; export { OptimizelyProvider } from './Provider'; export { OptimizelyFeature } from './Feature'; -export { useFeature, useExperiment, useDecision, useTrackEvents } from './hooks'; +export { useFeature, useExperiment, useDecision, useTrackEvent } from './hooks'; export { withOptimizely, WithOptimizelyProps, WithoutOptimizelyProps } from './withOptimizely'; export { OptimizelyExperiment } from './Experiment'; export { OptimizelyVariation } from './Variation';