diff --git a/src/client.ts b/src/client.ts index 02f01eff..9c40857e 100644 --- a/src/client.ts +++ b/src/client.ts @@ -16,7 +16,9 @@ import * as optimizely from '@optimizely/optimizely-sdk'; import * as logging from '@optimizely/js-sdk-logging'; + import { OptimizelyDecision, UserInfo, createFailedDecision } from './utils'; +import clientStore from './store'; const logger = logging.getLogger('ReactSDK'); @@ -143,30 +145,31 @@ export interface ReactSDKClient extends Omit void; private userPromise: Promise; private isUserPromiseResolved = false; @@ -213,7 +216,7 @@ class OptimizelyReactSDKClient implements ReactSDKClient { this.userPromiseResolver = resolve; }).then(() => { this.isUserReady = true; - return { success: true } + return { success: true }; }); this._client.onReady().then(() => { @@ -221,7 +224,6 @@ class OptimizelyReactSDKClient implements ReactSDKClient { }); this.dataReadyPromise = Promise.all([this.userPromise, this._client.onReady()]).then(() => { - // Client and user can become ready synchronously and/or asynchronously. This flag specifically indicates that they became ready asynchronously. this.isReadyPromiseFulfilled = true; return { @@ -231,7 +233,7 @@ class OptimizelyReactSDKClient implements ReactSDKClient { }); } - getIsReadyPromiseFulfilled(): boolean { + getIsReadyPromiseFulfilled(): boolean { return this.isReadyPromiseFulfilled; } @@ -263,6 +265,34 @@ class OptimizelyReactSDKClient implements ReactSDKClient { }); } + getUserContextInstance(userInfo: UserInfo): optimizely.OptimizelyUserContext | null { + let userContext: optimizely.OptimizelyUserContext | null = null; + + const userId = userInfo.id || this.user.id; + const userAttributes = userInfo.attributes || this.user.attributes; + + if (this.userContext) { + if (JSON.stringify(userInfo) === JSON.stringify(this.user)) { + return this.userContext; + } + + if (userId) { + userContext = this._client.createUserContext(userId, userAttributes); + return userContext; + } + + return null; + } + + if (userId) { + const userContext = this._client.createUserContext(userId, userAttributes); + this.userContext = userContext; + return this.userContext; + } + + return null; + } + setUser(userInfo: UserInfo): void { // TODO add check for valid user if (userInfo.id) { @@ -276,6 +306,8 @@ class OptimizelyReactSDKClient implements ReactSDKClient { this.userPromiseResolver(this.user); this.isUserPromiseResolved = true; } + + this.userContext = this.getUserContextInstance(userInfo); this.onUserUpdateHandlers.forEach(handler => handler(this.user)); } @@ -338,21 +370,22 @@ class OptimizelyReactSDKClient implements ReactSDKClient { options: optimizely.OptimizelyDecideOption[] = [], overrideUserId?: string, overrideAttributes?: optimizely.UserAttributes - ): OptimizelyDecision { + ): OptimizelyDecision { const user = this.getUserContextWithOverrides(overrideUserId, overrideAttributes); if (user.id === null) { logger.info('Not Evaluating feature "%s" because userId is not set', key); return createFailedDecision(key, `Not Evaluating flag ${key} because userId is not set`, user); - } - const optlyUserContext: optimizely.OptimizelyUserContext | null = this._client.createUserContext(user.id, user.attributes); + } + + const optlyUserContext = this.getUserContextInstance(user); if (optlyUserContext) { return { - ... optlyUserContext.decide(key, options), + ...optlyUserContext.decide(key, options), userContext: { id: user.id, - attributes: user.attributes - } - } + attributes: user.attributes, + }, + }; } return createFailedDecision(key, `Not Evaluating flag ${key} because user id or attributes are not valid`, user); } @@ -367,25 +400,28 @@ class OptimizelyReactSDKClient implements ReactSDKClient { if (user.id === null) { logger.info('Not Evaluating features because userId is not set'); return {}; - } - const optlyUserContext: optimizely.OptimizelyUserContext | null = this._client.createUserContext(user.id, user.attributes); + } + + const optlyUserContext = this.getUserContextInstance(user); if (optlyUserContext) { - return Object.entries(optlyUserContext.decideForKeys(keys, options)) - .reduce((decisions: { [key: string]: OptimizelyDecision }, [key, decision]) => { + return Object.entries(optlyUserContext.decideForKeys(keys, options)).reduce( + (decisions: { [key: string]: OptimizelyDecision }, [key, decision]) => { decisions[key] = { - ... decision, + ...decision, userContext: { id: user.id || '', attributes: user.attributes, - } - } + }, + }; return decisions; - }, {}); + }, + {} + ); } return {}; } - public decideAll( + public decideAll( options: optimizely.OptimizelyDecideOption[] = [], overrideUserId?: string, overrideAttributes?: optimizely.UserAttributes @@ -394,20 +430,23 @@ class OptimizelyReactSDKClient implements ReactSDKClient { if (user.id === null) { logger.info('Not Evaluating features because userId is not set'); return {}; - } - const optlyUserContext: optimizely.OptimizelyUserContext | null = this._client.createUserContext(user.id, user.attributes); + } + + const optlyUserContext = this.getUserContextInstance(user); if (optlyUserContext) { - return Object.entries(optlyUserContext.decideAll(options)) - .reduce((decisions: { [key: string]: OptimizelyDecision }, [key, decision]) => { + return Object.entries(optlyUserContext.decideAll(options)).reduce( + (decisions: { [key: string]: OptimizelyDecision }, [key, decision]) => { decisions[key] = { - ... decision, + ...decision, userContext: { id: user.id || '', attributes: user.attributes, - } - } + }, + }; return decisions; - }, {}); + }, + {} + ); } return {}; } @@ -462,6 +501,67 @@ class OptimizelyReactSDKClient implements ReactSDKClient { return this._client.track(eventKey, user.id, user.attributes, eventTags); } + /** + * Sets the forced decision for specified optimizely decision context. + * @param {optimizely.OptimizelyDecisionContext} decisionContext + * @param {optimizely.OptimizelyForcedDecision} forcedDecision + * @memberof OptimizelyReactSDKClient + */ + public setForcedDecision( + decisionContext: optimizely.OptimizelyDecisionContext, + decision: optimizely.OptimizelyForcedDecision + ): void { + if (!this.userContext) { + logger.info("Can't set a forced decision because the user context has not been set yet"); + return; + } + + const store = clientStore.getInstance(); + + this.userContext.setForcedDecision(decisionContext, decision); + store.setState({ + userContext: this.userContext, + }); + } + + /** + * Returns the forced decision for specified optimizely decision context. + * @param {optimizely.OptimizelyDecisionContext} decisionContext + * @return {(optimizely.OptimizelyForcedDecision | null)} + * @memberof OptimizelyReactSDKClient + */ + public getForcedDecision( + decisionContext: optimizely.OptimizelyDecisionContext + ): optimizely.OptimizelyForcedDecision | null { + if (!this.userContext) { + logger.info("Can't get a forced decision because the user context has not been set yet"); + return null; + } + return this.userContext.getForcedDecision(decisionContext); + } + + /** + * Removes the forced decision for specified optimizely decision context. + * @param {optimizely.OptimizelyDecisionContext} decisionContext + * @return {boolean} + * @memberof OptimizelyReactSDKClient + */ + public removeForcedDecision(decisionContext: optimizely.OptimizelyDecisionContext): boolean { + if (!this.userContext) { + logger.info("Can't remove a forced decision because the user context has not been set yet"); + return false; + } + + const store = clientStore.getInstance(); + const decision = this.userContext.removeForcedDecision(decisionContext); + + store.setState({ + userContext: this.userContext, + }); + + return decision; + } + /** * Returns true if the feature is enabled for the given user * @param {string} feature diff --git a/src/hooks.ts b/src/hooks.ts index 5ded0495..300c967f 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -15,11 +15,12 @@ */ import { useCallback, useContext, useEffect, useState, useRef } from 'react'; -import { UserAttributes, OptimizelyDecideOption } from '@optimizely/optimizely-sdk'; +import { UserAttributes, OptimizelyDecideOption, OptimizelyUserContext } from '@optimizely/optimizely-sdk'; import { getLogger, LoggerFacade } from '@optimizely/js-sdk-logging'; import { setupAutoUpdateListeners } from './autoUpdate'; import { ReactSDKClient, VariableValuesObject, OnReadyResult } from './client'; +import clientStore from './store'; import { OptimizelyContext } from './Context'; import { areAttributesEqual, OptimizelyDecision, createFailedDecision } from './utils'; @@ -342,23 +343,28 @@ export const useFeature: UseFeature = (featureKey, options = {}, overrides = {}) * ClientReady and DidTimeout provide signals to handle this scenario. */ export const useDecision: UseDecision = (flagKey, options = {}, overrides = {}) => { + const [userContext, setUserContext] = useState(null); + const store = clientStore.getInstance(); const { optimizely, isServerSide, timeout } = useContext(OptimizelyContext); if (!optimizely) { throw new Error('optimizely prop must be supplied via a parent '); } const overrideAttrs = useCompareAttrsMemoize(overrides.overrideAttributes); - const getCurrentDecision: () => { decision: OptimizelyDecision } = useCallback( - () => ({ - decision: optimizely.decide(flagKey, options.decideOptions, overrides.overrideUserId, overrideAttrs) - }), - [optimizely, flagKey, overrides.overrideUserId, overrideAttrs, options.decideOptions] - ); + const getCurrentDecision: () => { decision: OptimizelyDecision } = () => ({ + decision: optimizely.decide(flagKey, options.decideOptions, overrides.overrideUserId, overrideAttrs), + }); 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}) }; + const decisionState = isClientReady + ? getCurrentDecision() + : { + decision: createFailedDecision(flagKey, 'Optimizely SDK not configured properly yet.', { + id: overrides.overrideUserId || null, + attributes: overrideAttrs, + }), + }; return { ...decisionState, clientReady: isClientReady, @@ -388,7 +394,7 @@ export const useDecision: UseDecision = (flagKey, options = {}, overrides = {}) // 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 + // 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, finalReadyTimeout, initState => { @@ -400,6 +406,13 @@ export const useDecision: UseDecision = (flagKey, options = {}, overrides = {}) } }, []); + useEffect(() => { + // Subscribe to the observable store to listen to changes in the optimizely client. + store.subscribe(state => { + setUserContext(state.userContext); + }); + }, []); + useEffect(() => { // Subscribe to update after first datafile is fetched and readyPromise is resolved to avoid redundant rendering. if (optimizely.getIsReadyPromiseFulfilled() && options.autoUpdate) { @@ -413,5 +426,14 @@ export const useDecision: UseDecision = (flagKey, options = {}, overrides = {}) return (): void => {}; }, [optimizely.getIsReadyPromiseFulfilled(), options.autoUpdate, optimizely, flagKey, getCurrentDecision]); + useEffect(() => { + if (userContext) { + setState(prevState => ({ + ...prevState, + ...getCurrentDecision(), + })); + } + }, [userContext]); + return [state.decision, state.clientReady, state.didTimeout]; }; diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 00000000..bbb9e4ca --- /dev/null +++ b/src/store.ts @@ -0,0 +1,54 @@ +import { OptimizelyUserContext } from '@optimizely/optimizely-sdk'; + +interface iState { + userContext: OptimizelyUserContext | null; +} + +class Observable { + private observers: Array<(state: iState, prevState?: iState) => void>; + private state: iState; + + constructor() { + this.observers = []; + this.state = { + userContext: null, + }; + } + + subscribe(callback: (state: iState, prevState?: iState) => void) { + this.observers.push(callback); + } + + unsubscribe(callback: (state: iState, prevState?: iState) => void) { + this.observers = this.observers.filter(observer => observer !== callback); + } + + updateStore(newState: iState) { + return { ...this.state, ...JSON.parse(JSON.stringify(newState)) }; + } + + setState(newStore: iState) { + const prevState = { ...this.state }; + this.state = this.updateStore(newStore); + this.notify(prevState); + } + + notify(prevState: iState) { + this.observers.forEach(callback => callback(this.state, prevState)); + } +} + +const store = (function() { + let instance: Observable; + + return { + getInstance: function() { + if (!instance) { + instance = new Observable(); + } + return instance; + }, + }; +})(); + +export default store;