diff --git a/src/hooks.spec.tsx b/src/hooks.spec.tsx index 97f7273..ff150d1 100644 --- a/src/hooks.spec.tsx +++ b/src/hooks.spec.tsx @@ -22,7 +22,7 @@ import { render, renderHook, screen, waitFor } from '@testing-library/react'; import '@testing-library/jest-dom'; import { OptimizelyProvider } from './Provider'; -import { OnReadyResult, ReactSDKClient, VariableValuesObject } from './client'; +import { NotReadyReason, OnReadyResult, ReactSDKClient, VariableValuesObject } from './client'; import { useExperiment, useFeature, useDecision, useTrackEvent, hooksLogger } from './hooks'; import { OptimizelyDecision } from './utils'; const defaultDecision: OptimizelyDecision = { @@ -71,7 +71,6 @@ describe('hooks', () => { let notificationListenerCallbacks: Array<() => void>; let optimizelyMock: ReactSDKClient; let readySuccess: boolean; - // let reason: NotReadyReason; let userUpdateCallbacks: Array<() => void>; let UseExperimentLoggingComponent: React.FunctionComponent; let UseFeatureLoggingComponent: React.FunctionComponent; @@ -84,21 +83,38 @@ describe('hooks', () => { const REJECTION_REASON = 'A rejection reason you should never see in the test runner'; beforeEach(() => { - getOnReadyPromise = ({ timeout = 0 }: any): Promise => - new Promise((resolve) => { - setTimeout(function () { - resolve( - Object.assign( - { - success: readySuccess, - }, - !readySuccess && { - dataReadyPromise: new Promise((r) => setTimeout(r, mockDelay)), - } - ) - ); - }, timeout || mockDelay); + getOnReadyPromise = ({ timeout = 0 }: any): Promise => { + const timeoutPromise = new Promise((resolve) => { + setTimeout( + () => { + resolve({ + success: false, + reason: NotReadyReason.TIMEOUT, + dataReadyPromise: new Promise((r) => + setTimeout( + () => + r({ + success: readySuccess, + }), + mockDelay + ) + ), + }); + }, + timeout || mockDelay + 1 + ); + }); + + const clientAndUserReadyPromise = new Promise((resolve) => { + setTimeout(() => { + resolve({ + success: readySuccess, + }); + }, mockDelay); }); + + return Promise.race([clientAndUserReadyPromise, timeoutPromise]); + }; activateMock = jest.fn(); isFeatureEnabledMock = jest.fn(); featureVariables = mockFeatureVariables; @@ -176,52 +192,47 @@ describe('hooks', () => { it('should return a variation when activate returns a variation', async () => { activateMock.mockReturnValue('12345'); - const { container } = render( + render( ); - await optimizelyMock.onReady(); await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('12345|true|false')); }); it('should return null when activate returns null', async () => { activateMock.mockReturnValue(null); featureVariables = {}; - const { container } = render( + render( ); - await optimizelyMock.onReady(); await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('null|true|false')); }); it('should respect the timeout option passed', async () => { activateMock.mockReturnValue(null); + mockDelay = 20; readySuccess = false; render( - + ); - await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('null|false|false')); // initial render - - await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('null|false|true')); // when didTimeout - - // Simulate datafile fetch completing after timeout has already passed - // Activate now returns a variation + expect(screen.getByTestId('result')).toHaveTextContent('null|false|false'); // initial render + readySuccess = true; activateMock.mockReturnValue('12345'); - await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('12345|true|true')); // when clientReady + // When timeout is reached, but dataReadyPromise is resolved later with the variation + await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('12345|true|true')); }); it('should gracefully handle the client promise rejecting after timeout', async () => { - jest.useFakeTimers(); - readySuccess = false; activateMock.mockReturnValue('12345'); + getOnReadyPromise = (): Promise => new Promise((_, rej) => setTimeout(() => rej(REJECTION_REASON), mockDelay)); @@ -231,11 +242,7 @@ describe('hooks', () => { ); - jest.advanceTimersByTime(mockDelay + 1); - await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('null|false|false')); - - jest.useRealTimers(); }); it('should re-render when the user attributes change using autoUpdate', async () => { @@ -247,9 +254,6 @@ describe('hooks', () => { ); - // TODO - Wrap this with async act() once we upgrade to React 16.9 - // See https://github.com/facebook/react/issues/15379 - await optimizelyMock.onReady(); await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('null|true|false')); activateMock.mockReturnValue('12345'); @@ -257,8 +261,7 @@ describe('hooks', () => { await act(async () => { userUpdateCallbacks.forEach((fn) => fn()); }); - // component.update(); - // await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('12345|true|false'); + await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('12345|true|false')); }); @@ -270,9 +273,6 @@ describe('hooks', () => { ); - // // TODO - Wrap this with async act() once we upgrade to React 16.9 - // // See https://github.com/facebook/react/issues/15379 - await optimizelyMock.onReady(); await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('null|true|false')); activateMock.mockReturnValue('12345'); @@ -434,7 +434,6 @@ describe('hooks', () => { ); - await optimizelyMock.onReady(); await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('true|{"foo":"bar"}|true|false')); }); @@ -447,44 +446,33 @@ describe('hooks', () => { ); - await optimizelyMock.onReady(); await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('false|{}|true|false')); }); it('should respect the timeout option passed', async () => { + mockDelay = 20; isFeatureEnabledMock.mockReturnValue(false); featureVariables = {}; readySuccess = false; render( - + ); - await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('false|{}|false|false')); // initial render - - await optimizelyMock.onReady(); - await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('false|{}|false|true')); // when didTimeout + // Initial render + expect(screen.getByTestId('result')).toHaveTextContent('false|{}|false|false'); - // Simulate datafile fetch completing after timeout has already passed - // isFeatureEnabled now returns true, getFeatureVariables returns variable values + readySuccess = true; isFeatureEnabledMock.mockReturnValue(true); featureVariables = mockFeatureVariables; - // Wait for completion of dataReadyPromise - await optimizelyMock.onReady().then((res) => res.dataReadyPromise); - // Simulate datafile fetch completing after timeout has already passed - // Activate now returns a variation - activateMock.mockReturnValue('12345'); - // Wait for completion of dataReadyPromise - await optimizelyMock.onReady().then((res) => res.dataReadyPromise); - await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('true|{"foo":"bar"}|true|true')); // when clientReady + // When timeout is reached, but dataReadyPromise is resolved later with the feature enabled + await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('true|{"foo":"bar"}|true|true')); }); it('should gracefully handle the client promise rejecting after timeout', async () => { - jest.useFakeTimers(); - readySuccess = false; isFeatureEnabledMock.mockReturnValue(true); getOnReadyPromise = (): Promise => @@ -496,11 +484,7 @@ describe('hooks', () => { ); - jest.advanceTimersByTime(mockDelay + 1); - await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('false|{}|false|false')); - - jest.useRealTimers(); }); it('should re-render when the user attributes change using autoUpdate', async () => { @@ -513,9 +497,6 @@ describe('hooks', () => { ); - // TODO - Wrap this with async act() once we upgrade to React 16.9 - // See https://github.com/facebook/react/issues/15379 - await optimizelyMock.onReady(); await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('false|{}|true|false')); isFeatureEnabledMock.mockReturnValue(true); @@ -537,9 +518,6 @@ describe('hooks', () => { ); - // TODO - Wrap this with async act() once we upgrade to React 16.9 - // See https://github.com/facebook/react/issues/15379 - await optimizelyMock.onReady(); await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('false|{}|true|false')); isFeatureEnabledMock.mockReturnValue(true); @@ -548,7 +526,7 @@ describe('hooks', () => { act(() => { userUpdateCallbacks.forEach((fn) => fn()); }); - // component.update(); + await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('false|{}|true|false')); }); @@ -688,8 +666,6 @@ describe('hooks', () => { ); - await optimizelyMock.onReady(); - // component.update(); await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('true|{"foo":"bar"}|true|false')); }); @@ -699,50 +675,41 @@ describe('hooks', () => { enabled: false, variables: { foo: 'bar' }, }); + render( ); - await optimizelyMock.onReady(); + await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('false|{"foo":"bar"}|true|false')); }); it('should respect the timeout option passed', async () => { decideMock.mockReturnValue({ ...defaultDecision }); readySuccess = false; + mockDelay = 20; render( - + ); - await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('false|{}|false|false')); - await optimizelyMock.onReady(); - // component.update(); - await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('false|{}|false|true')); + // Initial render + expect(screen.getByTestId('result')).toHaveTextContent('false|{}|false|false'); - // Simulate datafile fetch completing after timeout has already passed - // flag is now true and decision contains variables decideMock.mockReturnValue({ ...defaultDecision, enabled: true, variables: { foo: 'bar' }, }); - - await optimizelyMock.onReady().then((res) => res.dataReadyPromise); - - // Simulate datafile fetch completing after timeout has already passed - // Wait for completion of dataReadyPromise - await optimizelyMock.onReady().then((res) => res.dataReadyPromise); - - await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('true|{"foo":"bar"}|true|true')); // when clientReady + readySuccess = true; + // When timeout is reached, but dataReadyPromise is resolved later with the decision value + await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('true|{"foo":"bar"}|true|true')); }); it('should gracefully handle the client promise rejecting after timeout', async () => { - jest.useFakeTimers(); - readySuccess = false; decideMock.mockReturnValue({ ...defaultDecision }); getOnReadyPromise = (): Promise => @@ -754,11 +721,7 @@ describe('hooks', () => { ); - jest.advanceTimersByTime(mockDelay + 1); - await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('false|{}|false|false')); - - jest.useRealTimers(); }); it('should re-render when the user attributes change using autoUpdate', async () => { @@ -769,9 +732,6 @@ describe('hooks', () => { ); - // TODO - Wrap this with async act() once we upgrade to React 16.9 - // See https://github.com/facebook/react/issues/15379 - await optimizelyMock.onReady(); await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('false|{}|true|false')); decideMock.mockReturnValue({ @@ -794,9 +754,6 @@ describe('hooks', () => { ); - // TODO - Wrap this with async act() once we upgrade to React 16.9 - // See https://github.com/facebook/react/issues/15379 - await optimizelyMock.onReady(); await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('false|{}|true|false')); decideMock.mockReturnValue({ @@ -819,7 +776,7 @@ describe('hooks', () => { ); - // component.update(); + expect(mockLog).toHaveBeenCalledTimes(1); expect(mockLog).toHaveBeenCalledWith(false); }); @@ -990,7 +947,6 @@ describe('hooks', () => { ); decideMock.mockReturnValue({ ...defaultDecision, variables: { foo: 'bar' } }); - await optimizelyMock.onReady(); await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('false|{"foo":"bar"}|true|false')); }); @@ -1016,7 +972,6 @@ describe('hooks', () => { { variationKey: 'var2' } ); - await optimizelyMock.onReady(); await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('false|{}|true|false')); }); @@ -1044,7 +999,6 @@ describe('hooks', () => { { variationKey: 'var2' } ); - await optimizelyMock.onReady(); await waitFor(() => expect(screen.getByTestId('result')).toHaveTextContent('false|{}|true|false')); }); }); diff --git a/src/hooks.ts b/src/hooks.ts index 8a2d928..a84b266 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -14,8 +14,7 @@ * limitations under the License. */ -/* eslint-disable react-hooks/rules-of-hooks */ -import { useCallback, useContext, useEffect, useState, useRef } from 'react'; +import { useCallback, useContext, useEffect, useState, useRef, useMemo } from 'react'; import { UserAttributes, OptimizelyDecideOption, getLogger } from '@optimizely/optimizely-sdk'; @@ -26,6 +25,7 @@ import { OptimizelyContext } from './Context'; import { areAttributesEqual, OptimizelyDecision, createFailedDecision } from './utils'; export const hooksLogger = getLogger('ReactSDK'); +const optimizelyPropError = "The 'optimizely' prop must be supplied via a parent "; enum HookType { EXPERIMENT = 'Experiment', @@ -148,10 +148,18 @@ function subscribeToInitialization( clientReady: false, didTimeout: false, }); - res.dataReadyPromise?.then(() => { - hooksLogger.info('Client became ready.'); + res.dataReadyPromise?.then((readyResult?: OnReadyResult) => { + if (!readyResult) { + return; + } + const { success, message } = readyResult; + if (success) { + hooksLogger.info('Client became ready.'); + } else { + hooksLogger.warn(`Client not ready, reason="${message}"`); + } onInitStateChange({ - clientReady: true, + clientReady: success, didTimeout: false, }); }); @@ -162,10 +170,18 @@ function subscribeToInitialization( clientReady: false, didTimeout: false, }); - res.dataReadyPromise?.then(() => { - hooksLogger.info('User became ready later.'); + res.dataReadyPromise?.then((readyResult?: OnReadyResult) => { + if (!readyResult) { + return; + } + const { success, message } = readyResult; + if (success) { + hooksLogger.info('User became ready later.'); + } else { + hooksLogger.warn(`Client not ready, reason="${message}"`); + } onInitStateChange({ - clientReady: true, + clientReady: success, didTimeout: false, }); }); @@ -176,10 +192,21 @@ function subscribeToInitialization( clientReady: false, didTimeout: true, }); - res.dataReadyPromise?.then(() => { - hooksLogger.info('Client became ready after timeout already elapsed'); + res.dataReadyPromise?.then((readyResult?: OnReadyResult) => { + if (!readyResult) { + return; + } + + const { success, message } = readyResult; + + if (success) { + hooksLogger.info('Client became ready after timeout already elapsed'); + } else { + hooksLogger.warn(`Client not ready, reason="${message}"`); + } + onInitStateChange({ - clientReady: true, + clientReady: success, didTimeout: true, }); }); @@ -188,13 +215,23 @@ function subscribeToInitialization( hooksLogger.warn(`Other reason client not ready, reason="${res.message}"`); onInitStateChange({ clientReady: false, - didTimeout: true, // assume timeout + didTimeout: false, }); - res.dataReadyPromise?.then(() => { - hooksLogger.info('Client became ready later'); + res.dataReadyPromise?.then((readyResult?: OnReadyResult) => { + if (!readyResult) { + return; + } + + const { success, message } = readyResult; + + if (success) { + hooksLogger.info('Client became ready later'); + } else { + hooksLogger.warn(`Client not ready, reason="${message}"`); + } onInitStateChange({ - clientReady: true, - didTimeout: true, // assume timeout + clientReady: success, + didTimeout: false, }); }); } @@ -222,22 +259,18 @@ function useCompareAttrsMemoize(value: UserAttributes | undefined): UserAttribut export const useExperiment: UseExperiment = (experimentKey, options = {}, overrides = {}) => { const { optimizely, isServerSide, timeout } = useContext(OptimizelyContext); - if (!optimizely) { - hooksLogger.error( - `Unable to use experiment ${experimentKey}. optimizely prop must be supplied via a parent ` - ); - return [null, false, false]; - } - const overrideAttrs = useCompareAttrsMemoize(overrides.overrideAttributes); + const getCurrentDecision: () => ExperimentDecisionValues = useCallback( () => ({ - variation: optimizely.activate(experimentKey, overrides.overrideUserId, overrideAttrs), + variation: optimizely?.activate(experimentKey, overrides.overrideUserId, overrideAttrs) || null, }), [optimizely, experimentKey, overrides.overrideUserId, overrideAttrs] ); - const isClientReady = isServerSide || optimizely.isReady(); + const isClientReady = isServerSide || !!optimizely?.isReady(); + const isReadyPromiseFulfilled = !!optimizely?.getIsReadyPromiseFulfilled(); + const [state, setState] = useState(() => { const decisionState = isClientReady ? getCurrentDecision() : { variation: null }; return { @@ -256,6 +289,7 @@ export const useExperiment: UseExperiment = (experimentKey, options = {}, overri overrideUserId: overrides.overrideUserId, overrideAttributes: overrideAttrs, }; + const [prevDecisionInputs, setPrevDecisionInputs] = useState(currentDecisionInputs); if (!areDecisionInputsEqual(prevDecisionInputs, currentDecisionInputs)) { setPrevDecisionInputs(currentDecisionInputs); @@ -272,7 +306,7 @@ export const useExperiment: UseExperiment = (experimentKey, options = {}, overri // 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() && !optimizely.getIsReadyPromiseFulfilled()) || !isClientReady) { + if (optimizely && ((optimizely.getIsUsingSdkKey() && !isReadyPromiseFulfilled) || !isClientReady)) { subscribeToInitialization(optimizely, finalReadyTimeout, (initState) => { setState({ ...getCurrentDecision(), @@ -280,11 +314,11 @@ export const useExperiment: UseExperiment = (experimentKey, options = {}, overri }); }); } - }, []); + }, [finalReadyTimeout, getCurrentDecision, isClientReady, isReadyPromiseFulfilled, optimizely]); useEffect(() => { // Subscribe to update after first datafile is fetched and readyPromise is resolved to avoid redundant rendering. - if (optimizely.getIsReadyPromiseFulfilled() && options.autoUpdate) { + if (optimizely && isReadyPromiseFulfilled && options.autoUpdate) { return setupAutoUpdateListeners(optimizely, HookType.EXPERIMENT, experimentKey, hooksLogger, () => { setState((prevState) => ({ ...prevState, @@ -293,11 +327,11 @@ export const useExperiment: UseExperiment = (experimentKey, options = {}, overri }); } return (): void => {}; - }, [optimizely.getIsReadyPromiseFulfilled(), options.autoUpdate, optimizely, experimentKey, getCurrentDecision]); + }, [isReadyPromiseFulfilled, options.autoUpdate, optimizely, experimentKey, getCurrentDecision]); useEffect( () => - optimizely.onForcedVariationsUpdate(() => { + optimizely?.onForcedVariationsUpdate(() => { setState((prevState) => ({ ...prevState, ...getCurrentDecision(), @@ -305,6 +339,11 @@ export const useExperiment: UseExperiment = (experimentKey, options = {}, overri }), [getCurrentDecision, optimizely] ); + + if (!optimizely) { + hooksLogger.error(`Unable to use experiment ${experimentKey}. ${optimizelyPropError}`); + } + return [state.variation, state.clientReady, state.didTimeout]; }; @@ -317,24 +356,19 @@ export const useExperiment: UseExperiment = (experimentKey, options = {}, overri */ export const useFeature: UseFeature = (featureKey, options = {}, overrides = {}) => { const { optimizely, isServerSide, timeout } = useContext(OptimizelyContext); - - if (!optimizely) { - hooksLogger.error( - `Unable to properly use feature ${featureKey}. optimizely prop must be supplied via a parent ` - ); - return [false, {}, false, false]; - } - const overrideAttrs = useCompareAttrsMemoize(overrides.overrideAttributes); + const getCurrentDecision: () => FeatureDecisionValues = useCallback( () => ({ - isEnabled: optimizely.isFeatureEnabled(featureKey, overrides.overrideUserId, overrideAttrs), - variables: optimizely.getFeatureVariables(featureKey, overrides.overrideUserId, overrideAttrs), + isEnabled: !!optimizely?.isFeatureEnabled(featureKey, overrides.overrideUserId, overrideAttrs), + variables: optimizely?.getFeatureVariables(featureKey, overrides.overrideUserId, overrideAttrs) || {}, }), [optimizely, featureKey, overrides.overrideUserId, overrideAttrs] ); - const isClientReady = isServerSide || optimizely.isReady(); + const isClientReady = isServerSide || !!optimizely?.isReady(); + const isReadyPromiseFulfilled = !!optimizely?.getIsReadyPromiseFulfilled(); + const [state, setState] = useState(() => { const decisionState = isClientReady ? getCurrentDecision() : { isEnabled: false, variables: {} }; return { @@ -352,7 +386,9 @@ export const useFeature: UseFeature = (featureKey, options = {}, overrides = {}) overrideUserId: overrides.overrideUserId, overrideAttributes: overrides.overrideAttributes, }; + const [prevDecisionInputs, setPrevDecisionInputs] = useState(currentDecisionInputs); + if (!areDecisionInputsEqual(prevDecisionInputs, currentDecisionInputs)) { setPrevDecisionInputs(currentDecisionInputs); setState((prevState) => ({ @@ -362,13 +398,14 @@ export const useFeature: UseFeature = (featureKey, options = {}, overrides = {}) } const finalReadyTimeout = options.timeout !== undefined ? options.timeout : timeout; + useEffect(() => { // Subscribe to initialzation 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) { + if (optimizely && (optimizely.getIsUsingSdkKey() || !isClientReady)) { subscribeToInitialization(optimizely, finalReadyTimeout, (initState) => { setState({ ...getCurrentDecision(), @@ -376,11 +413,12 @@ export const useFeature: UseFeature = (featureKey, options = {}, overrides = {}) }); }); } - }, []); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [finalReadyTimeout, getCurrentDecision, optimizely]); useEffect(() => { // Subscribe to update after first datafile is fetched and readyPromise is resolved to avoid redundant rendering. - if (optimizely.getIsReadyPromiseFulfilled() && options.autoUpdate) { + if (optimizely && isReadyPromiseFulfilled && options.autoUpdate) { return setupAutoUpdateListeners(optimizely, HookType.FEATURE, featureKey, hooksLogger, () => { setState((prevState) => ({ ...prevState, @@ -389,7 +427,11 @@ export const useFeature: UseFeature = (featureKey, options = {}, overrides = {}) }); } return (): void => {}; - }, [optimizely.getIsReadyPromiseFulfilled(), options.autoUpdate, optimizely, featureKey, getCurrentDecision]); + }, [isReadyPromiseFulfilled, options.autoUpdate, optimizely, featureKey, getCurrentDecision]); + + if (!optimizely) { + hooksLogger.error(`Unable to properly use feature ${featureKey}. ${optimizelyPropError}`); + } return [state.isEnabled, state.variables, state.clientReady, state.didTimeout]; }; @@ -404,35 +446,33 @@ export const useFeature: UseFeature = (featureKey, options = {}, overrides = {}) export const useDecision: UseDecision = (flagKey, options = {}, overrides = {}) => { const { optimizely, isServerSide, timeout } = useContext(OptimizelyContext); - if (!optimizely) { - hooksLogger.error( - `Unable to use decision ${flagKey}. optimizely prop must be supplied via a parent ` - ); - return [ + const overrideAttrs = useCompareAttrsMemoize(overrides.overrideAttributes); + + const defaultDecision = useMemo( + () => createFailedDecision(flagKey, 'Optimizely SDK not configured properly yet.', { - id: null, - attributes: {}, + id: overrides.overrideUserId || null, + attributes: overrideAttrs || {}, }), - false, - false, - ]; - } + [flagKey, overrideAttrs, overrides.overrideUserId] + ); - const overrideAttrs = useCompareAttrsMemoize(overrides.overrideAttributes); + const getCurrentDecision: () => { decision: OptimizelyDecision } = useCallback( + () => ({ + decision: + optimizely?.decide(flagKey, options.decideOptions, overrides.overrideUserId, overrideAttrs) || defaultDecision, + }), + [flagKey, defaultDecision, optimizely, options.decideOptions, overrideAttrs, overrides.overrideUserId] + ); - const getCurrentDecision: () => { decision: OptimizelyDecision } = () => ({ - decision: optimizely.decide(flagKey, options.decideOptions, overrides.overrideUserId, overrideAttrs), - }); + const isClientReady = isServerSide || !!optimizely?.isReady(); + const isReadyPromiseFulfilled = !!optimizely?.getIsReadyPromiseFulfilled(); - const isClientReady = isServerSide || optimizely.isReady(); const [state, setState] = useState<{ decision: OptimizelyDecision } & InitializationState>(() => { const decisionState = isClientReady ? getCurrentDecision() : { - decision: createFailedDecision(flagKey, 'Optimizely SDK not configured properly yet.', { - id: overrides.overrideUserId || null, - attributes: overrideAttrs, - }), + decision: defaultDecision, }; return { ...decisionState, @@ -459,13 +499,14 @@ export const useDecision: UseDecision = (flagKey, options = {}, overrides = {}) } const finalReadyTimeout = options.timeout !== undefined ? options.timeout : timeout; + useEffect(() => { // Subscribe to initialzation 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) { + if (optimizely && (optimizely.getIsUsingSdkKey() || !isClientReady)) { subscribeToInitialization(optimizely, finalReadyTimeout, (initState) => { setState({ ...getCurrentDecision(), @@ -473,7 +514,8 @@ export const useDecision: UseDecision = (flagKey, options = {}, overrides = {}) }); }); } - }, []); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [finalReadyTimeout, getCurrentDecision, optimizely]); useEffect(() => { if (overrides.overrideUserId || overrides.overrideAttributes || !options.autoUpdate) { @@ -487,11 +529,11 @@ export const useDecision: UseDecision = (flagKey, options = {}, overrides = {}) ...getCurrentDecision(), })); }); - }, [overrides.overrideUserId, overrides.overrideAttributes, options.autoUpdate]); + }, [overrides.overrideUserId, overrides.overrideAttributes, options.autoUpdate, flagKey, getCurrentDecision]); useEffect(() => { // Subscribe to update after first datafile is fetched and readyPromise is resolved to avoid redundant rendering. - if (optimizely.getIsReadyPromiseFulfilled() && options.autoUpdate) { + if (optimizely && isReadyPromiseFulfilled && options.autoUpdate) { return setupAutoUpdateListeners(optimizely, HookType.FEATURE, flagKey, hooksLogger, () => { setState((prevState) => ({ ...prevState, @@ -500,19 +542,23 @@ export const useDecision: UseDecision = (flagKey, options = {}, overrides = {}) }); } return (): void => {}; - }, [optimizely.getIsReadyPromiseFulfilled(), options.autoUpdate, optimizely, flagKey, getCurrentDecision]); + }, [isReadyPromiseFulfilled, options.autoUpdate, optimizely, flagKey, getCurrentDecision]); + + if (!optimizely) { + hooksLogger.error(`Unable to use decision ${flagKey}. ${optimizelyPropError}`); + } return [state.decision, state.clientReady, state.didTimeout]; }; export const useTrackEvent: UseTrackEvent = () => { const { optimizely, isServerSide, timeout } = useContext(OptimizelyContext); - const isClientReady = !!(isServerSide || optimizely?.isReady()); + 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 `); + hooksLogger.error(`Unable to track events. ${optimizelyPropError}`); return; } if (!isClientReady) { @@ -524,10 +570,6 @@ export const useTrackEvent: UseTrackEvent = () => { [optimizely, isClientReady] ); - if (!optimizely) { - return [track, false, false]; - } - const [state, setState] = useState<{ clientReady: boolean; didTimeout: DidTimeout; @@ -544,12 +586,13 @@ export const useTrackEvent: UseTrackEvent = () => { // 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) { + if (optimizely && (optimizely.getIsUsingSdkKey() || !isClientReady)) { subscribeToInitialization(optimizely, timeout, (initState) => { setState(initState); }); } - }, []); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [optimizely, timeout]); return [track, state.clientReady, state.didTimeout]; };