From e50aa5b7ff40ae685a47297cfc9630226ed7f628 Mon Sep 17 00:00:00 2001 From: Cristian Paredes Date: Fri, 12 Nov 2021 17:50:12 -0500 Subject: [PATCH 1/5] POC: Implementing an observer memoize and listen to changes in the optimizely client. --- src/client.ts | 167 +++++++++++++++++++++++++++++++++++++--------- src/hooks.ts | 42 +++++++++--- src/store.spec.ts | 106 +++++++++++++++++++++++++++++ src/store.ts | 52 +++++++++++++++ src/utils.tsx | 35 ++++++++-- 5 files changed, 354 insertions(+), 48 deletions(-) create mode 100644 src/store.spec.ts create mode 100644 src/store.ts diff --git a/src/client.ts b/src/client.ts index 02f01eff..509a60a3 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 { OptimizelyDecision, UserInfo, createFailedDecision, areObjectsEqual } 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,11 +265,38 @@ class OptimizelyReactSDKClient implements ReactSDKClient { }); } + getUserContextInstance(userInfo: UserInfo): optimizely.OptimizelyUserContext | null { + let userContext: optimizely.OptimizelyUserContext | null = null; + + if (this.userContext) { + if (areObjectsEqual(userInfo, this.user)) { + return this.userContext; + } + + if (userInfo.id) { + userContext = this._client.createUserContext(userInfo.id, userInfo.attributes); + return userContext; + } + + return null; + } + + if (userInfo.id) { + this.userContext = this._client.createUserContext(userInfo.id, userInfo.attributes); + return this.userContext; + } + + return null; + } + setUser(userInfo: UserInfo): void { // TODO add check for valid user if (userInfo.id) { + const userContext = this._client.createUserContext(userInfo.id, userInfo.attributes); + this.user.id = userInfo.id; this.isUserReady = true; + this.userContext = userContext; } if (userInfo.attributes) { this.user.attributes = userInfo.attributes; @@ -276,6 +305,11 @@ class OptimizelyReactSDKClient implements ReactSDKClient { this.userPromiseResolver(this.user); this.isUserPromiseResolved = true; } + + const store = clientStore.getInstance(); + store.setState({ + lastUserUpdate: new Date(), + }); this.onUserUpdateHandlers.forEach(handler => handler(this.user)); } @@ -338,21 +372,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 +402,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 +432,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 +503,68 @@ 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({ + lastUserUpdate: new Date(), + }); + } + + /** + * 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({ + lastUserUpdate: new Date(), + }); + + 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..c13f47a3 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -20,6 +20,7 @@ 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 [lastUserUpdate, setLastUserUpdate] = 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,15 @@ export const useDecision: UseDecision = (flagKey, options = {}, overrides = {}) } }, []); + useEffect(() => { + // Subscribe to the observable store to listen to changes in the optimizely client. + store.subscribe(state => { + if (state.lastUserUpdate) { + setLastUserUpdate(state.lastUserUpdate); + } + }); + }, []); + useEffect(() => { // Subscribe to update after first datafile is fetched and readyPromise is resolved to avoid redundant rendering. if (optimizely.getIsReadyPromiseFulfilled() && options.autoUpdate) { @@ -413,5 +428,14 @@ export const useDecision: UseDecision = (flagKey, options = {}, overrides = {}) return (): void => {}; }, [optimizely.getIsReadyPromiseFulfilled(), options.autoUpdate, optimizely, flagKey, getCurrentDecision]); + useEffect(() => { + if (lastUserUpdate) { + setState(prevState => ({ + ...prevState, + ...getCurrentDecision(), + })); + } + }, [lastUserUpdate]); + return [state.decision, state.clientReady, state.didTimeout]; }; diff --git a/src/store.spec.ts b/src/store.spec.ts new file mode 100644 index 00000000..98c8eddf --- /dev/null +++ b/src/store.spec.ts @@ -0,0 +1,106 @@ +/** + * Copyright 2019-2020, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import clientStore, { Observable, iStoreState } from './store'; + +describe('store', () => { + const store = clientStore.getInstance(); + + it('should be defined', () => { + expect(clientStore).toBeDefined(); + }); + + it('should be an instance of Observable', () => { + expect(store).toBeInstanceOf(Observable); + }); + + it('should have a subscribe method', () => { + expect(store.subscribe).toBeDefined(); + }); + + it('should have an unsubscribe method', () => { + expect(store.unsubscribe).toBeDefined(); + }); + + it('should have an updateStore method', () => { + expect(store.updateStore).toBeDefined(); + }); + + it('should have a setState method', () => { + expect(store.setState).toBeDefined(); + }); + + it('should have a notify method', () => { + expect(store.notify).toBeDefined(); + }); + + describe('when multiple instances', () => { + const store2 = clientStore.getInstance(); + const store3 = clientStore.getInstance(); + + it('all instances should point to the same reference', () => { + expect(store).toBe(store2); + expect(store).toBe(store3); + expect(store2).toBe(store3); + }); + }); + + describe('when subscribing', () => { + let callback: jest.MockedFunction<() => void>; + + beforeEach(() => { + callback = jest.fn(); + store.subscribe(callback); + }); + + describe('when updating the store', () => { + let updatedState: iStoreState; + + beforeEach(() => { + updatedState = { + lastUserUpdate: new Date(), + }; + + store.setState(updatedState); + }); + + it('should call the callback', () => { + expect(callback).toHaveBeenCalledWith(updatedState, { lastUserUpdate: null }); + }); + }); + + describe('when unsubscribing', () => { + beforeEach(() => { + store.unsubscribe(callback); + }); + + describe('when updating the store', () => { + let updatedState: iStoreState; + + beforeEach(() => { + updatedState = { + lastUserUpdate: new Date(), + }; + + store.setState(updatedState); + }); + + it('should not call the callback', () => { + expect(callback).not.toHaveBeenCalled(); + }); + }); + }); + }); +}); diff --git a/src/store.ts b/src/store.ts new file mode 100644 index 00000000..785035ad --- /dev/null +++ b/src/store.ts @@ -0,0 +1,52 @@ +export interface iStoreState { + lastUserUpdate?: Date | null; +} + +export class Observable { + private observers: Array<(state: iStoreState, prevState?: iStoreState) => void>; + private state: iStoreState; + + constructor() { + this.observers = []; + this.state = { + lastUserUpdate: null, + }; + } + + subscribe(callback: (state: iStoreState, prevState?: iStoreState) => void) { + this.observers.push(callback); + } + + unsubscribe(callback: (state: iStoreState, prevState?: iStoreState) => void) { + this.observers = this.observers.filter(observer => observer !== callback); + } + + updateStore(newState: iStoreState) { + return { ...this.state, ...newState }; + } + + setState(newStore: iStoreState) { + const prevState = { ...this.state }; + this.state = this.updateStore(newStore); + this.notify(prevState); + } + + notify(prevState: iStoreState) { + 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; diff --git a/src/utils.tsx b/src/utils.tsx index c79fb91c..16dd56a0 100644 --- a/src/utils.tsx +++ b/src/utils.tsx @@ -24,8 +24,8 @@ export type UserInfo = { }; export interface OptimizelyDecision extends Omit { - userContext: UserInfo -}; + userContext: UserInfo; +} export function areUsersEqual(user1: UserInfo, user2: UserInfo): boolean { if (user1.id !== user2.id) { @@ -37,8 +37,8 @@ export function areUsersEqual(user1: UserInfo, user2: UserInfo): boolean { user1keys.sort(); user2keys.sort(); - const user1Attributes = user1.attributes || {} - const user2Attributes = user2.attributes || {} + const user1Attributes = user1.attributes || {}; + const user2Attributes = user2.attributes || {}; const areKeysLenEqual = user1keys.length === user2keys.length; if (!areKeysLenEqual) { @@ -60,6 +60,27 @@ export function areUsersEqual(user1: UserInfo, user2: UserInfo): boolean { return true; } +export function areObjectsEqual(obj1: any, obj2: any) { + const obj1Keys = Object.keys(obj1); + const obj2Keys = Object.keys(obj2); + + if (obj1Keys.length !== obj2Keys.length) { + return false; + } + + for (let i = 0; i < obj1Keys.length; i += 1) { + const key = obj1Keys[i]; + if (typeof obj1[key] === 'object' && typeof obj2[key] === 'object') { + if (!areObjectsEqual(obj1[key], obj2[key])) { + return false; + } + } else if (obj1[key] !== obj2[key]) { + return false; + } + } + return true; +} + export interface AcceptsForwardedRef { forwardedRef?: React.Ref; } @@ -119,7 +140,7 @@ export function createFailedDecision(flagKey: string, message: string, user: Use reasons: [message], userContext: { id: user.id, - attributes: user.attributes - } - } + attributes: user.attributes, + }, + }; } From cd97e91f69be4e965886e73f418fc24cad7a3dd6 Mon Sep 17 00:00:00 2001 From: Zeeshan Ashraf <35262377+zashraf1985@users.noreply.github.com> Date: Thu, 6 Jan 2022 15:13:54 +0500 Subject: [PATCH 2/5] Merge Sherry's contribution to forced decisions from his fork (#138) Sherry was working from his fork due to access issues. Now merging it back to the same branch in base repo. --- package.json | 2 +- src/client.spec.ts | 257 ++++++++++++++++++++++++++++++++++++++++--- src/client.ts | 68 ++++++++---- src/hooks.spec.tsx | 166 +++++++++++++++++++++++++--- src/hooks.ts | 30 ++--- src/notifier.spec.ts | 139 +++++++++++++++++++++++ src/notifier.ts | 52 +++++++++ src/store.spec.ts | 106 ------------------ src/store.ts | 52 --------- yarn.lock | 51 +++++---- 10 files changed, 668 insertions(+), 255 deletions(-) create mode 100644 src/notifier.spec.ts create mode 100644 src/notifier.ts delete mode 100644 src/store.spec.ts delete mode 100644 src/store.ts diff --git a/package.json b/package.json index 3314fb36..8ebefb5c 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ }, "dependencies": { "@optimizely/js-sdk-logging": "^0.1.0", - "@optimizely/optimizely-sdk": "^4.7.0", + "@optimizely/optimizely-sdk": "^4.8.0", "hoist-non-react-statics": "^3.3.0", "prop-types": "^15.6.2", "utility-types": "^2.1.0 || ^3.0.0" diff --git a/src/client.spec.ts b/src/client.spec.ts index 1e45c9ca..4a2a6f12 100644 --- a/src/client.spec.ts +++ b/src/client.spec.ts @@ -33,6 +33,9 @@ describe('ReactSDKClient', () => { decide: jest.fn(), decideAll: jest.fn(), decideForKeys: jest.fn(), + setForcedDecision: jest.fn(), + removeForcedDecision: jest.fn(), + removeAllForcedDecisions: jest.fn(), } as any; mockInnerClient = { @@ -526,7 +529,7 @@ describe('ReactSDKClient', () => { const mockFn = mockOptimizelyUserContext.decideAll as jest.Mock; const mockCreateUserContext = mockInnerClient.createUserContext as jest.Mock; mockFn.mockReturnValue({ - 'theFlag1': { + theFlag1: { enabled: true, flagKey: 'theFlag1', reasons: [], @@ -534,11 +537,11 @@ describe('ReactSDKClient', () => { userContext: mockOptimizelyUserContext, variables: {}, variationKey: 'varition1', - } + }, }); let result = instance.decideAll(); expect(result).toEqual({ - 'theFlag1': { + theFlag1: { enabled: true, flagKey: 'theFlag1', reasons: [], @@ -549,14 +552,14 @@ describe('ReactSDKClient', () => { }, variables: {}, variationKey: 'varition1', - } + }, }); expect(mockFn).toBeCalledTimes(1); expect(mockFn).toBeCalledWith([]); - expect(mockCreateUserContext).toBeCalledWith('user1', { foo: 'bar' }); + expect(mockCreateUserContext).toBeCalledWith('user1', { foo: 'bar' }); mockFn.mockReset(); mockFn.mockReturnValue({ - 'theFlag2': { + theFlag2: { enabled: true, flagKey: 'theFlag2', reasons: [], @@ -564,11 +567,11 @@ describe('ReactSDKClient', () => { userContext: mockOptimizelyUserContext, variables: {}, variationKey: 'varition2', - } + }, }); result = instance.decideAll([optimizely.OptimizelyDecideOption.INCLUDE_REASONS], 'user2', { bar: 'baz' }); expect(result).toEqual({ - 'theFlag2': { + theFlag2: { enabled: true, flagKey: 'theFlag2', reasons: [], @@ -579,7 +582,7 @@ describe('ReactSDKClient', () => { }, variables: {}, variationKey: 'varition2', - } + }, }); expect(mockFn).toBeCalledTimes(1); expect(mockFn).toBeCalledWith([optimizely.OptimizelyDecideOption.INCLUDE_REASONS]); @@ -590,7 +593,7 @@ describe('ReactSDKClient', () => { const mockFn = mockOptimizelyUserContext.decideForKeys as jest.Mock; const mockCreateUserContext = mockInnerClient.createUserContext as jest.Mock; mockFn.mockReturnValue({ - 'theFlag1': { + theFlag1: { enabled: true, flagKey: 'theFlag1', reasons: [], @@ -598,11 +601,11 @@ describe('ReactSDKClient', () => { userContext: mockOptimizelyUserContext, variables: {}, variationKey: 'varition1', - } + }, }); let result = instance.decideForKeys(['theFlag1']); expect(result).toEqual({ - 'theFlag1': { + theFlag1: { enabled: true, flagKey: 'theFlag1', reasons: [], @@ -613,14 +616,14 @@ describe('ReactSDKClient', () => { }, variables: {}, variationKey: 'varition1', - } + }, }); expect(mockFn).toBeCalledTimes(1); expect(mockFn).toBeCalledWith(['theFlag1'], []); expect(mockCreateUserContext).toBeCalledWith('user1', { foo: 'bar' }); mockFn.mockReset(); mockFn.mockReturnValue({ - 'theFlag2': { + theFlag2: { enabled: true, flagKey: 'theFlag2', reasons: [], @@ -628,11 +631,13 @@ describe('ReactSDKClient', () => { userContext: mockOptimizelyUserContext, variables: {}, variationKey: 'varition2', - } + }, + }); + result = instance.decideForKeys(['theFlag1'], [optimizely.OptimizelyDecideOption.INCLUDE_REASONS], 'user2', { + bar: 'baz', }); - result = instance.decideForKeys(['theFlag1'], [optimizely.OptimizelyDecideOption.INCLUDE_REASONS], 'user2', { bar: 'baz' }); expect(result).toEqual({ - 'theFlag2': { + theFlag2: { enabled: true, flagKey: 'theFlag2', reasons: [], @@ -643,7 +648,7 @@ describe('ReactSDKClient', () => { }, variables: {}, variationKey: 'varition2', - } + }, }); expect(mockFn).toBeCalledTimes(1); expect(mockFn).toBeCalledWith(['theFlag1'], [optimizely.OptimizelyDecideOption.INCLUDE_REASONS]); @@ -856,4 +861,220 @@ describe('ReactSDKClient', () => { expect(handler).not.toBeCalled(); }); }); + + describe('removeAllForcedDecisions', () => { + let instance: ReactSDKClient; + beforeEach(() => { + instance = createInstance(config); + }); + + it('should return false if no user context has been set ', () => { + const mockFn = mockOptimizelyUserContext.removeAllForcedDecisions as jest.Mock; + + mockFn.mockReturnValue(false); + + const result = instance.removeAllForcedDecisions(); + expect(result).toBeDefined(); + expect(result).toEqual(false); + }); + + it('should return true if user context has been set ', () => { + instance.setUser({ + id: 'user1', + }); + const mockFn = mockOptimizelyUserContext.removeAllForcedDecisions as jest.Mock; + + mockFn.mockReturnValue(true); + + const result = instance.removeAllForcedDecisions(); + expect(mockFn).toBeCalledTimes(1); + expect(result).toBeDefined(); + expect(result).toEqual(true); + }); + }); + + describe('setForcedDecision', () => { + let instance: ReactSDKClient; + beforeEach(() => { + instance = createInstance(config); + instance.setUser({ + id: 'user1', + attributes: { + foo: 'bar', + }, + }); + }); + + it('should overwrite decide when forcedDecision is envoked', () => { + const mockFn = mockOptimizelyUserContext.decide as jest.Mock; + mockFn.mockReturnValue({ + enabled: true, + flagKey: 'theFlag1', + reasons: [], + ruleKey: '', + userContext: mockOptimizelyUserContext, + variables: {}, + variationKey: 'varition1', + }); + const result = instance.decide('theFlag1'); + expect(result).toEqual({ + enabled: true, + flagKey: 'theFlag1', + reasons: [], + ruleKey: '', + userContext: { + id: 'user1', + attributes: { foo: 'bar' }, + }, + variables: {}, + variationKey: 'varition1', + }); + expect(mockFn).toBeCalledTimes(1); + expect(mockFn).toBeCalledWith('theFlag1', []); + + const mockFnForcedDecision = mockOptimizelyUserContext.setForcedDecision as jest.Mock; + mockFnForcedDecision.mockReturnValue(true); + instance.setForcedDecision( + { + flagKey: 'theFlag1', + ruleKey: 'experiment', + }, + { variationKey: 'varition2' } + ); + + expect(mockFnForcedDecision).toBeCalledTimes(1); + + mockFn.mockReset(); + mockFn.mockReturnValue({ + enabled: true, + flagKey: 'theFlag1', + reasons: [], + ruleKey: '', + userContext: mockOptimizelyUserContext, + variables: {}, + variationKey: 'varition2', + }); + const result2 = instance.decide('theFlag1', []); + + expect(mockFn).toBeCalledTimes(1); + expect(mockFn).toBeCalledWith('theFlag1', []); + expect(result2).toEqual({ + enabled: true, + flagKey: 'theFlag1', + reasons: [], + ruleKey: '', + userContext: { id: 'user1', attributes: { foo: 'bar' } }, + variables: {}, + variationKey: 'varition2', + }); + }); + }); + + describe('removeForcedDecision', () => { + let instance: ReactSDKClient; + beforeEach(() => { + instance = createInstance(config); + instance.setUser({ + id: 'user1', + attributes: { + foo: 'bar', + }, + }); + }); + + it('should revert back to the decide default value when removeForcedDecision is envoked after settingup the forced decision', () => { + const mockFn = mockOptimizelyUserContext.decide as jest.Mock; + mockFn.mockReturnValue({ + enabled: true, + flagKey: 'theFlag1', + reasons: [], + ruleKey: '', + userContext: mockOptimizelyUserContext, + variables: {}, + variationKey: 'varition1', + }); + const result = instance.decide('theFlag1'); + expect(result).toEqual({ + enabled: true, + flagKey: 'theFlag1', + reasons: [], + ruleKey: '', + userContext: { + id: 'user1', + attributes: { foo: 'bar' }, + }, + variables: {}, + variationKey: 'varition1', + }); + expect(mockFn).toBeCalledTimes(1); + expect(mockFn).toBeCalledWith('theFlag1', []); + + const mockFnForcedDecision = mockOptimizelyUserContext.setForcedDecision as jest.Mock; + mockFnForcedDecision.mockReturnValue(true); + instance.setForcedDecision( + { + flagKey: 'theFlag1', + ruleKey: 'experiment', + }, + { variationKey: 'varition2' } + ); + + expect(mockFnForcedDecision).toBeCalledTimes(1); + + mockFn.mockReset(); + mockFn.mockReturnValue({ + enabled: true, + flagKey: 'theFlag1', + reasons: [], + ruleKey: '', + userContext: mockOptimizelyUserContext, + variables: {}, + variationKey: 'varition2', + }); + const result2 = instance.decide('theFlag1', []); + + expect(mockFn).toBeCalledTimes(1); + expect(mockFn).toBeCalledWith('theFlag1', []); + expect(result2).toEqual({ + enabled: true, + flagKey: 'theFlag1', + reasons: [], + ruleKey: '', + userContext: { id: 'user1', attributes: { foo: 'bar' } }, + variables: {}, + variationKey: 'varition2', + }); + + const mockFnRemoveForcedDecision = mockOptimizelyUserContext.removeForcedDecision as jest.Mock; + mockFnRemoveForcedDecision.mockReturnValue(true); + instance.removeForcedDecision({ + flagKey: 'theFlag1', + ruleKey: 'experiment', + }); + + mockFn.mockReset(); + mockFn.mockReturnValue({ + enabled: true, + flagKey: 'theFlag1', + reasons: [], + ruleKey: '', + userContext: mockOptimizelyUserContext, + variables: {}, + variationKey: 'varition1', + }); + const result3 = instance.decide('theFlag1', []); + + expect(mockFn).toBeCalledTimes(1); + expect(mockFn).toBeCalledWith('theFlag1', []); + expect(result3).toEqual({ + enabled: true, + flagKey: 'theFlag1', + reasons: [], + ruleKey: '', + userContext: { id: 'user1', attributes: { foo: 'bar' } }, + variables: {}, + variationKey: 'varition1', + }); + }); + }); }); diff --git a/src/client.ts b/src/client.ts index 509a60a3..e513b405 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,5 +1,5 @@ /** - * Copyright 2019-2020, Optimizely + * Copyright 2019-2021, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -17,8 +17,8 @@ import * as optimizely from '@optimizely/optimizely-sdk'; import * as logging from '@optimizely/js-sdk-logging'; -import { OptimizelyDecision, UserInfo, createFailedDecision, areObjectsEqual } from './utils'; -import clientStore from './store'; +import { OptimizelyDecision, UserInfo, createFailedDecision, areUsersEqual } from './utils'; +import { notifier } from './notifier'; const logger = logging.getLogger('ReactSDK'); @@ -159,6 +159,15 @@ export interface ReactSDKClient extends Omit = new Set(); // Is the javascript SDK instance ready. private isClientReady: boolean = false; @@ -269,7 +279,7 @@ class OptimizelyReactSDKClient implements ReactSDKClient { let userContext: optimizely.OptimizelyUserContext | null = null; if (this.userContext) { - if (areObjectsEqual(userInfo, this.user)) { + if (areUsersEqual(userInfo, this.user)) { return this.userContext; } @@ -298,18 +308,16 @@ class OptimizelyReactSDKClient implements ReactSDKClient { this.isUserReady = true; this.userContext = userContext; } + if (userInfo.attributes) { this.user.attributes = userInfo.attributes; } + if (!this.isUserPromiseResolved) { this.userPromiseResolver(this.user); this.isUserPromiseResolved = true; } - const store = clientStore.getInstance(); - store.setState({ - lastUserUpdate: new Date(), - }); this.onUserUpdateHandlers.forEach(handler => handler(this.user)); } @@ -518,13 +526,12 @@ class OptimizelyReactSDKClient implements ReactSDKClient { return; } - const store = clientStore.getInstance(); + const isSuccess = this.userContext.setForcedDecision(decisionContext, decision); - this.userContext.setForcedDecision(decisionContext, decision); - - store.setState({ - lastUserUpdate: new Date(), - }); + if (isSuccess) { + this.forcedDecisionFlagKeys.add(decisionContext.flagKey); + notifier.notify(decisionContext.flagKey); + } } /** @@ -550,19 +557,40 @@ class OptimizelyReactSDKClient implements ReactSDKClient { * @memberof OptimizelyReactSDKClient */ public removeForcedDecision(decisionContext: optimizely.OptimizelyDecisionContext): boolean { + if (!this.userContext) { + logger.info("Can't remove forced decisions because the user context has not been set yet"); + return false; + } + + const isSuccess = this.userContext.removeForcedDecision(decisionContext); + + if (isSuccess) { + this.forcedDecisionFlagKeys.delete(decisionContext.flagKey); + notifier.notify(decisionContext.flagKey); + } + + return isSuccess; + } + + /** + * Removes all the forced decision. + * @return {boolean} + * @memberof OptimizelyReactSDKClient + */ + public removeAllForcedDecisions(): 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); + const isSuccess = this.userContext.removeAllForcedDecisions(); - store.setState({ - lastUserUpdate: new Date(), - }); + if (isSuccess) { + this.forcedDecisionFlagKeys.forEach(flagKey => notifier.notify(flagKey)); + this.forcedDecisionFlagKeys.clear(); + } - return decision; + return isSuccess; } /** diff --git a/src/hooks.spec.tsx b/src/hooks.spec.tsx index fb15c7a2..1df7d264 100644 --- a/src/hooks.spec.tsx +++ b/src/hooks.spec.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2020, Optimizely + * Copyright 2021, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -47,7 +47,9 @@ const MyExperimentComponent = ({ options = {}, overrides = {} }: any) => { const MyDecideComponent = ({ options = {}, overrides = {} }: any) => { const [decision, clientReady, didTimeout] = useDecision('feature1', { ...options }, { ...overrides }); - return <>{`${(decision.enabled) ? 'true' : 'false'}|${JSON.stringify(decision.variables)}|${clientReady}|${didTimeout}`}; + return ( + <>{`${decision.enabled ? 'true' : 'false'}|${JSON.stringify(decision.variables)}|${clientReady}|${didTimeout}`} + ); }; const mockFeatureVariables: VariableValuesObject = { @@ -70,6 +72,7 @@ describe('hooks', () => { let mockLog: jest.Mock; let forcedVariationUpdateCallbacks: Array<() => void>; let decideMock: jest.Mock; + let setForcedDecisionMock: jest.Mock; beforeEach(() => { getOnReadyPromise = ({ timeout = 0 }: any): Promise => @@ -97,6 +100,7 @@ describe('hooks', () => { notificationListenerCallbacks = []; forcedVariationUpdateCallbacks = []; decideMock = jest.fn(); + setForcedDecisionMock = jest.fn(); optimizelyMock = ({ activate: activateMock, @@ -126,6 +130,7 @@ describe('hooks', () => { }), getForcedVariations: jest.fn().mockReturnValue({}), decide: decideMock, + setForcedDecision: setForcedDecisionMock, } as unknown) as ReactSDKClient; mockLog = jest.fn(); @@ -673,9 +678,9 @@ describe('hooks', () => { describe('useDecision', () => { it('should render true when the flag is enabled', async () => { decideMock.mockReturnValue({ - ... defaultDecision, + ...defaultDecision, enabled: true, - variables: { 'foo': 'bar' }, + variables: { foo: 'bar' }, }); const component = Enzyme.mount( @@ -687,11 +692,11 @@ describe('hooks', () => { expect(component.text()).toBe('true|{"foo":"bar"}|true|false'); }); - it('should render false when the flag is disabled', async () => { + it('should render false when the flag is disabled', async () => { decideMock.mockReturnValue({ - ... defaultDecision, + ...defaultDecision, enabled: false, - variables: { 'foo': 'bar' }, + variables: { foo: 'bar' }, }); const component = Enzyme.mount( @@ -704,7 +709,7 @@ describe('hooks', () => { }); it('should respect the timeout option passed', async () => { - decideMock.mockReturnValue({ ... defaultDecision }); + decideMock.mockReturnValue({ ...defaultDecision }); readySuccess = false; const component = Enzyme.mount( @@ -721,26 +726,25 @@ describe('hooks', () => { // Simulate datafile fetch completing after timeout has already passed // flag is now true and decision contains variables decideMock.mockReturnValue({ - ... defaultDecision, + ...defaultDecision, enabled: true, - variables: { 'foo': 'bar' }, + variables: { foo: 'bar' }, }); await optimizelyMock.onReady().then(res => res.dataReadyPromise); component.update(); - // Simulate datafile fetch completing after timeout has already passed + // Simulate datafile fetch completing after timeout has already passed // Wait for completion of dataReadyPromise await optimizelyMock.onReady().then(res => res.dataReadyPromise); component.update(); - + expect(component.text()).toBe('true|{"foo":"bar"}|true|true'); // when clientReady }); it('should gracefully handle the client promise rejecting after timeout', async () => { - console.log('hola') readySuccess = false; - decideMock.mockReturnValue({ ... defaultDecision }); + decideMock.mockReturnValue({ ...defaultDecision }); getOnReadyPromise = () => new Promise((res, rej) => { setTimeout(() => rej('some error with user'), mockDelay); @@ -773,7 +777,7 @@ describe('hooks', () => { decideMock.mockReturnValue({ ...defaultDecision, enabled: true, - variables: { 'foo': 'bar' } + variables: { foo: 'bar' }, }); // Simulate the user object changing act(() => { @@ -800,8 +804,8 @@ describe('hooks', () => { decideMock.mockReturnValue({ ...defaultDecision, enabled: true, - variables: { 'foo': 'bar' } - }); + variables: { foo: 'bar' }, + }); // Simulate the user object changing act(() => { userUpdateCallbacks.forEach(fn => fn()); @@ -899,7 +903,7 @@ describe('hooks', () => { component.update(); expect(component.text()).toBe('true|{}|true|false'); - decideMock.mockReturnValue({ ...defaultDecision, enabled: false, variables: { myvar: 3 } }); + decideMock.mockReturnValue({ ...defaultDecision, enabled: false, variables: { myvar: 3 } }); component.setProps({ children: ( { component.update(); expect(decideMock).not.toHaveBeenCalled(); }); + + it('should not recompute the decision when autoupdate is not passed and setting setForcedDecision', async () => { + decideMock.mockReturnValue({ ...defaultDecision, flagKey: 'exp1' }); + const component = Enzyme.mount( + + + + ); + + component.update(); + expect(component.text()).toBe('false|{}|true|false'); + optimizelyMock.setForcedDecision( + { + flagKey: 'exp1', + ruleKey: 'experiment', + }, + { variationKey: 'var2' } + ); + + component.update(); + expect(component.text()).toBe('false|{}|true|false'); + }); + + it('should not recompute the decision when autoupdate is false and setting setForcedDecision', async () => { + decideMock.mockReturnValue({ ...defaultDecision, flagKey: 'exp1' }); + const component = Enzyme.mount( + + + + ); + + component.update(); + expect(component.text()).toBe('false|{}|true|false'); + optimizelyMock.setForcedDecision( + { + flagKey: 'exp1', + ruleKey: 'experiment', + }, + { variationKey: 'var2' } + ); + + component.update(); + expect(component.text()).toBe('false|{}|true|false'); + }); + + it('should recompute the decision when autoupdate is true and setting setForcedDecision', async () => { + decideMock.mockReturnValue({ ...defaultDecision, flagKey: 'exp1' }); + const component = Enzyme.mount( + + + + ); + + component.update(); + expect(component.text()).toBe('false|{}|true|false'); + optimizelyMock.setForcedDecision( + { + flagKey: 'exp1', + ruleKey: 'experiment', + }, + { variationKey: 'var2' } + ); + + decideMock.mockReturnValue({ ...defaultDecision, variables: { foo: 'bar' } }); + await optimizelyMock.onReady(); + component.update(); + expect(component.text()).toBe('false|{"foo":"bar"}|true|false'); + }); + + it('should not recompute the decision if autoupdate is true but overrideUserId is passed and setting setForcedDecision', async () => { + decideMock.mockReturnValue({ ...defaultDecision, flagKey: 'exp1' }); + const component = Enzyme.mount( + + + + ); + + component.update(); + expect(component.text()).toBe('false|{}|true|false'); + optimizelyMock.setForcedDecision( + { + flagKey: 'exp1', + ruleKey: 'experiment', + }, + { variationKey: 'var2' } + ); + + await optimizelyMock.onReady(); + component.update(); + expect(component.text()).toBe('false|{}|true|false'); + }); + + it('should not recompute the decision if autoupdate is true but overrideAttributes are passed and setting setForcedDecision', async () => { + decideMock.mockReturnValue({ ...defaultDecision, flagKey: 'exp1' }); + const component = Enzyme.mount( + + + + ); + + component.update(); + expect(component.text()).toBe('false|{}|true|false'); + optimizelyMock.setForcedDecision( + { + flagKey: 'exp1', + ruleKey: 'experiment', + }, + { variationKey: 'var2' } + ); + + await optimizelyMock.onReady(); + component.update(); + expect(component.text()).toBe('false|{}|true|false'); + }); }); }); diff --git a/src/hooks.ts b/src/hooks.ts index c13f47a3..8997f11a 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -20,7 +20,7 @@ import { getLogger, LoggerFacade } from '@optimizely/js-sdk-logging'; import { setupAutoUpdateListeners } from './autoUpdate'; import { ReactSDKClient, VariableValuesObject, OnReadyResult } from './client'; -import clientStore from './store'; +import { notifier } from './notifier'; import { OptimizelyContext } from './Context'; import { areAttributesEqual, OptimizelyDecision, createFailedDecision } from './utils'; @@ -343,8 +343,6 @@ export const useFeature: UseFeature = (featureKey, options = {}, overrides = {}) * ClientReady and DidTimeout provide signals to handle this scenario. */ export const useDecision: UseDecision = (flagKey, options = {}, overrides = {}) => { - const [lastUserUpdate, setLastUserUpdate] = 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 '); @@ -407,13 +405,18 @@ export const useDecision: UseDecision = (flagKey, options = {}, overrides = {}) }, []); useEffect(() => { - // Subscribe to the observable store to listen to changes in the optimizely client. - store.subscribe(state => { - if (state.lastUserUpdate) { - setLastUserUpdate(state.lastUserUpdate); - } + if (overrides.overrideUserId || overrides.overrideAttributes || !options.autoUpdate) { + return; + } + + // Subscribe to Forced Decision changes. + return notifier.subscribe(flagKey, () => { + setState(prevState => ({ + ...prevState, + ...getCurrentDecision(), + })); }); - }, []); + }, [overrides.overrideUserId, overrides.overrideAttributes, options.autoUpdate]); useEffect(() => { // Subscribe to update after first datafile is fetched and readyPromise is resolved to avoid redundant rendering. @@ -428,14 +431,5 @@ export const useDecision: UseDecision = (flagKey, options = {}, overrides = {}) return (): void => {}; }, [optimizely.getIsReadyPromiseFulfilled(), options.autoUpdate, optimizely, flagKey, getCurrentDecision]); - useEffect(() => { - if (lastUserUpdate) { - setState(prevState => ({ - ...prevState, - ...getCurrentDecision(), - })); - } - }, [lastUserUpdate]); - return [state.decision, state.clientReady, state.didTimeout]; }; diff --git a/src/notifier.spec.ts b/src/notifier.spec.ts new file mode 100644 index 00000000..160384a2 --- /dev/null +++ b/src/notifier.spec.ts @@ -0,0 +1,139 @@ +/** + * Copyright 2019-2020, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +import { notifier } from './notifier'; + +describe('notifier', () => { + it('should have a subscribe method defined', () => { + expect(notifier.subscribe).toBeDefined(); + }); + + it('should have a notify method defined', () => { + expect(notifier.notify).toBeDefined(); + }); + + describe('Subscribing single key', () => { + let callback: jest.MockedFunction<() => void>; + const key: string = 'key_1'; + + beforeEach(() => { + callback = jest.fn(); + notifier.subscribe(key, callback); + }); + + describe('when notify event envoked with the relevent key', () => { + beforeEach(() => { + notifier.notify(key); + }); + + it('should call the callback', () => { + expect(callback).toHaveBeenCalled(); + }); + + it('should call the callback once only', () => { + expect(callback).toHaveBeenCalledTimes(1); + }); + }); + + describe('when notify event envoked with the irrelevant key', () => { + beforeEach(() => { + notifier.notify('another_key'); + }); + + it('should not call the callback', () => { + expect(callback).not.toHaveBeenCalled(); + }); + }); + }); + + describe('Subscribing multiple key', () => { + let callback1: jest.MockedFunction<() => void>; + const key1: string = 'key_1'; + let callback2: jest.MockedFunction<() => void>; + const key2: string = 'key_2'; + + beforeEach(() => { + callback1 = jest.fn(); + callback2 = jest.fn(); + notifier.subscribe(key1, callback1); + notifier.subscribe(key2, callback2); + }); + + describe('notifing particular key', () => { + beforeEach(() => { + notifier.notify(key1); + }); + + it('should call the callback of key 1 only', () => { + expect(callback1).toHaveBeenCalledTimes(1); + }); + + it('should not call the callback of key 2', () => { + expect(callback2).not.toHaveBeenCalled(); + }); + }); + }); + + describe('Subscribing similar key with multiple instances', () => { + let callback1: jest.MockedFunction<() => void>; + const sameKey1: string = 'key_1'; + let callback2: jest.MockedFunction<() => void>; + const sameKey2: string = 'key_1'; + + beforeEach(() => { + callback1 = jest.fn(); + callback2 = jest.fn(); + notifier.subscribe(sameKey1, callback1); + notifier.subscribe(sameKey2, callback2); + }); + describe('when notifing the key', () => { + beforeEach(() => { + notifier.notify(sameKey1); + }); + + it('should call all the callbacks of particular key', () => { + expect(callback1).toHaveBeenCalledTimes(1); + expect(callback2).toHaveBeenCalledTimes(1); + }); + }); + }); + + describe('unsubscribing the key', () => { + let callback: jest.MockedFunction<() => void>; + const key: string = 'key_1'; + + beforeEach(() => { + callback = jest.fn(); + }); + describe('subscribe should return a function', () => { + it('should call the callback', () => { + const unsubscribe = notifier.subscribe(key, callback); + expect(unsubscribe).toBeInstanceOf(Function); + }); + }); + + describe('should not envoke callback on notify if is unsubscribed', () => { + beforeEach(() => { + const unsubscribe = notifier.subscribe(key, callback); + unsubscribe(); + notifier.notify(key); + }); + + it('should not call the callback', () => { + expect(callback).not.toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/src/notifier.ts b/src/notifier.ts new file mode 100644 index 00000000..56e6d79f --- /dev/null +++ b/src/notifier.ts @@ -0,0 +1,52 @@ +/** + * Copyright 2021, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +export interface INotifier { + subscribe(key: string, callback: () => void): () => void; + notify(key: string): void; +} + +class Notifier implements INotifier { + private observers: Array<{subscriptionId: string; key: string; callback: () => void; }> = []; + private static instance: INotifier; + + private constructor() {} + + static getInstance(): INotifier { + if (!Notifier.instance) { + Notifier.instance = new Notifier(); + } + return Notifier.instance; + } + + subscribe(key: string, callback: () => void): () => void { + const subscriptionId = `key-${Math.floor(100000 + Math.random() * 999999)}`; + this.observers.push({ subscriptionId, key, callback }); + + return () => { + const observerIndex = this.observers.findIndex(observer => observer.subscriptionId === subscriptionId) + if (observerIndex >= 0) { + this.observers.splice(observerIndex, 1); + } + }; + } + + notify(key: string) { + this.observers.filter(observer => observer.key === key).forEach(observer => observer.callback()); + } +} + +export const notifier: INotifier = Notifier.getInstance(); diff --git a/src/store.spec.ts b/src/store.spec.ts deleted file mode 100644 index 98c8eddf..00000000 --- a/src/store.spec.ts +++ /dev/null @@ -1,106 +0,0 @@ -/** - * Copyright 2019-2020, Optimizely - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -import clientStore, { Observable, iStoreState } from './store'; - -describe('store', () => { - const store = clientStore.getInstance(); - - it('should be defined', () => { - expect(clientStore).toBeDefined(); - }); - - it('should be an instance of Observable', () => { - expect(store).toBeInstanceOf(Observable); - }); - - it('should have a subscribe method', () => { - expect(store.subscribe).toBeDefined(); - }); - - it('should have an unsubscribe method', () => { - expect(store.unsubscribe).toBeDefined(); - }); - - it('should have an updateStore method', () => { - expect(store.updateStore).toBeDefined(); - }); - - it('should have a setState method', () => { - expect(store.setState).toBeDefined(); - }); - - it('should have a notify method', () => { - expect(store.notify).toBeDefined(); - }); - - describe('when multiple instances', () => { - const store2 = clientStore.getInstance(); - const store3 = clientStore.getInstance(); - - it('all instances should point to the same reference', () => { - expect(store).toBe(store2); - expect(store).toBe(store3); - expect(store2).toBe(store3); - }); - }); - - describe('when subscribing', () => { - let callback: jest.MockedFunction<() => void>; - - beforeEach(() => { - callback = jest.fn(); - store.subscribe(callback); - }); - - describe('when updating the store', () => { - let updatedState: iStoreState; - - beforeEach(() => { - updatedState = { - lastUserUpdate: new Date(), - }; - - store.setState(updatedState); - }); - - it('should call the callback', () => { - expect(callback).toHaveBeenCalledWith(updatedState, { lastUserUpdate: null }); - }); - }); - - describe('when unsubscribing', () => { - beforeEach(() => { - store.unsubscribe(callback); - }); - - describe('when updating the store', () => { - let updatedState: iStoreState; - - beforeEach(() => { - updatedState = { - lastUserUpdate: new Date(), - }; - - store.setState(updatedState); - }); - - it('should not call the callback', () => { - expect(callback).not.toHaveBeenCalled(); - }); - }); - }); - }); -}); diff --git a/src/store.ts b/src/store.ts deleted file mode 100644 index 785035ad..00000000 --- a/src/store.ts +++ /dev/null @@ -1,52 +0,0 @@ -export interface iStoreState { - lastUserUpdate?: Date | null; -} - -export class Observable { - private observers: Array<(state: iStoreState, prevState?: iStoreState) => void>; - private state: iStoreState; - - constructor() { - this.observers = []; - this.state = { - lastUserUpdate: null, - }; - } - - subscribe(callback: (state: iStoreState, prevState?: iStoreState) => void) { - this.observers.push(callback); - } - - unsubscribe(callback: (state: iStoreState, prevState?: iStoreState) => void) { - this.observers = this.observers.filter(observer => observer !== callback); - } - - updateStore(newState: iStoreState) { - return { ...this.state, ...newState }; - } - - setState(newStore: iStoreState) { - const prevState = { ...this.state }; - this.state = this.updateStore(newStore); - this.notify(prevState); - } - - notify(prevState: iStoreState) { - 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; diff --git a/yarn.lock b/yarn.lock index e92f3166..e9b7f245 100644 --- a/yarn.lock +++ b/yarn.lock @@ -468,21 +468,21 @@ "@types/yargs" "^15.0.0" chalk "^4.0.0" -"@optimizely/js-sdk-datafile-manager@^0.8.1": - version "0.8.1" - resolved "https://registry.yarnpkg.com/@optimizely/js-sdk-datafile-manager/-/js-sdk-datafile-manager-0.8.1.tgz#b491bf56ac713a66344f8b26fdbaaee14e0b4365" - integrity sha512-zMfyXQUqJlPoFGTNvreGSneGRnr5hn4jp03ofipIpA/RONNsf7DEi/H/uC4pAZxlYm1r5eHZRwKU6gwZTB31LQ== +"@optimizely/js-sdk-datafile-manager@^0.9.1": + version "0.9.1" + resolved "https://registry.yarnpkg.com/@optimizely/js-sdk-datafile-manager/-/js-sdk-datafile-manager-0.9.1.tgz#46eef70bf59c93bdba481908994b9cec550d3049" + integrity sha512-AaAu1zPtPY3/qsVp5UwGS77aB8gxyQ1GxvzzOlN/40Y5MbBN8ul2HnUVwl9ZfUlPVunBhe1cw5o1H5/T0IrZYA== dependencies: - "@optimizely/js-sdk-logging" "^0.1.0" + "@optimizely/js-sdk-logging" "^0.3.1" "@optimizely/js-sdk-utils" "^0.4.0" decompress-response "^4.2.1" -"@optimizely/js-sdk-event-processor@^0.8.2": - version "0.8.2" - resolved "https://registry.yarnpkg.com/@optimizely/js-sdk-event-processor/-/js-sdk-event-processor-0.8.2.tgz#28ad138e09c614ed6282bd4bd2e5b219ec9a8ed6" - integrity sha512-5sVcQFqgKF0R+vJbBXy6ykKTlEfll0Ti0xGeKU3TLILRNvPDxTpVAlyrLfBC/yfF/hopjRPusGp3z9lZnVej0w== +"@optimizely/js-sdk-event-processor@^0.9.2": + version "0.9.2" + resolved "https://registry.yarnpkg.com/@optimizely/js-sdk-event-processor/-/js-sdk-event-processor-0.9.2.tgz#900f11703fb55695104bf6952f65978583f58d5b" + integrity sha512-9qkvGlvUYytGtQhJExOcjS0pgd04ABlmbQ/ZOdOEZA0pgtAiCwG+LaDnksQQdqAKgyEm/vc5A2ndXJEVy2nP0A== dependencies: - "@optimizely/js-sdk-logging" "^0.1.0" + "@optimizely/js-sdk-logging" "^0.3.1" "@optimizely/js-sdk-utils" "^0.4.0" "@optimizely/js-sdk-logging@^0.1.0": @@ -492,6 +492,13 @@ dependencies: "@optimizely/js-sdk-utils" "^0.1.0" +"@optimizely/js-sdk-logging@^0.3.1": + version "0.3.1" + resolved "https://registry.yarnpkg.com/@optimizely/js-sdk-logging/-/js-sdk-logging-0.3.1.tgz#358b48f4ce2ce22b1969d9e3e929caac1e6e6351" + integrity sha512-K71Jf283FP0E4oXehcXTTM3gvgHZHr7FUrIsw//0mdJlotHJT4Nss4hE0CWPbBxO7LJAtwNnO+VIA/YOcO4vHg== + dependencies: + "@optimizely/js-sdk-utils" "^0.4.0" + "@optimizely/js-sdk-utils@^0.1.0": version "0.1.0" resolved "https://registry.yarnpkg.com/@optimizely/js-sdk-utils/-/js-sdk-utils-0.1.0.tgz#e3ac1fef81f11c15774f4743c3fa7c65d9c3352a" @@ -506,16 +513,16 @@ dependencies: uuid "^3.3.2" -"@optimizely/optimizely-sdk@^4.7.0": - version "4.7.0" - resolved "https://registry.yarnpkg.com/@optimizely/optimizely-sdk/-/optimizely-sdk-4.7.0.tgz#4f05368e1fd23e642da2606d8c23e7759af3b0ed" - integrity sha512-62XfmRGOiJOBP9LQ7bTVu4okTKiHVOx22Mv8p3gxetJINdqcL508JrBgAMiWe8bueuR4pfS2/orstAsceAVCsw== +"@optimizely/optimizely-sdk@^4.8.0": + version "4.8.0" + resolved "https://registry.yarnpkg.com/@optimizely/optimizely-sdk/-/optimizely-sdk-4.8.0.tgz#c29e6840342ca56948656c873660b5dcea06aebe" + integrity sha512-x64O4XJG1Xux7i+sBR0kqpQ17AD9XhQ5rhSl0/pdlIBVSyU/HUXwAuFUv9O1ZgnKw6Am2LsLAEckrbkYt6q00g== dependencies: - "@optimizely/js-sdk-datafile-manager" "^0.8.1" - "@optimizely/js-sdk-event-processor" "^0.8.2" - "@optimizely/js-sdk-logging" "^0.1.0" + "@optimizely/js-sdk-datafile-manager" "^0.9.1" + "@optimizely/js-sdk-event-processor" "^0.9.2" + "@optimizely/js-sdk-logging" "^0.3.1" "@optimizely/js-sdk-utils" "^0.4.0" - json-schema "^0.2.3" + json-schema "^0.4.0" murmurhash "0.0.2" "@rollup/plugin-commonjs@^16.0.0": @@ -3262,10 +3269,10 @@ json-schema@0.2.3: resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.3.tgz#b480c892e59a2f05954ce727bd3f2a4e882f9e13" integrity sha1-tIDIkuWaLwWVTOcnvT8qTogvnhM= -json-schema@^0.2.3: - version "0.2.5" - resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.2.5.tgz#97997f50972dd0500214e208c407efa4b5d7063b" - integrity sha512-gWJOWYFrhQ8j7pVm0EM8Slr+EPVq1Phf6lvzvD/WCeqkrx/f2xBI0xOsRRS9xCn3I4vKtP519dvs3TP09r24wQ== +json-schema@^0.4.0: + version "0.4.0" + resolved "https://registry.yarnpkg.com/json-schema/-/json-schema-0.4.0.tgz#f7de4cf6efab838ebaeb3236474cbba5a1930ab5" + integrity sha512-es94M3nTIfsEPisRafak+HDLfHXnKBhV3vU5eqPcS3flIWqcxJWgXHXiey3YrpaNsanY5ei1VoYEbOzijuq9BA== json-stable-stringify-without-jsonify@^1.0.1: version "1.0.1" From 4e2f39e6407395a75d76e352e806aba7e8c5e8e2 Mon Sep 17 00:00:00 2001 From: shaharyarsheikh Date: Thu, 6 Jan 2022 15:42:32 +0500 Subject: [PATCH 3/5] Updated copyright year --- src/client.spec.ts | 2 +- src/client.ts | 2 +- src/hooks.spec.tsx | 2 +- src/hooks.ts | 2 +- src/notifier.spec.ts | 2 +- src/notifier.ts | 8 ++++---- 6 files changed, 9 insertions(+), 9 deletions(-) diff --git a/src/client.spec.ts b/src/client.spec.ts index 4a2a6f12..fdd2b516 100644 --- a/src/client.spec.ts +++ b/src/client.spec.ts @@ -1,5 +1,5 @@ /** - * Copyright 2019-2020, Optimizely + * Copyright 2019-2022, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/client.ts b/src/client.ts index e513b405..20b40761 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,5 +1,5 @@ /** - * Copyright 2019-2021, Optimizely + * Copyright 2019-2022, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/hooks.spec.tsx b/src/hooks.spec.tsx index 1df7d264..8bcde621 100644 --- a/src/hooks.spec.tsx +++ b/src/hooks.spec.tsx @@ -1,5 +1,5 @@ /** - * Copyright 2021, Optimizely + * Copyright 2022, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/hooks.ts b/src/hooks.ts index 8997f11a..a6dec605 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -1,5 +1,5 @@ /** - * Copyright 2020, Optimizely + * Copyright 2022, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/notifier.spec.ts b/src/notifier.spec.ts index 160384a2..87a9e7a1 100644 --- a/src/notifier.spec.ts +++ b/src/notifier.spec.ts @@ -1,5 +1,5 @@ /** - * Copyright 2019-2020, Optimizely + * Copyright 2019-2022, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. diff --git a/src/notifier.ts b/src/notifier.ts index 56e6d79f..a064da65 100644 --- a/src/notifier.ts +++ b/src/notifier.ts @@ -1,5 +1,5 @@ /** - * Copyright 2021, Optimizely + * Copyright 2022, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -20,7 +20,7 @@ export interface INotifier { } class Notifier implements INotifier { - private observers: Array<{subscriptionId: string; key: string; callback: () => void; }> = []; + private observers: Array<{ subscriptionId: string; key: string; callback: () => void }> = []; private static instance: INotifier; private constructor() {} @@ -37,13 +37,13 @@ class Notifier implements INotifier { this.observers.push({ subscriptionId, key, callback }); return () => { - const observerIndex = this.observers.findIndex(observer => observer.subscriptionId === subscriptionId) + const observerIndex = this.observers.findIndex(observer => observer.subscriptionId === subscriptionId); if (observerIndex >= 0) { this.observers.splice(observerIndex, 1); } }; } - + notify(key: string) { this.observers.filter(observer => observer.key === key).forEach(observer => observer.callback()); } From 99ebcef131e3594ffcf5c94fdca138a870f2bbb4 Mon Sep 17 00:00:00 2001 From: shaharyarsheikh Date: Mon, 17 Jan 2022 10:38:25 +0500 Subject: [PATCH 4/5] updated optimizely-sdk version to ^4.9.0 --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 8ebefb5c..dcaac90b 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ }, "dependencies": { "@optimizely/js-sdk-logging": "^0.1.0", - "@optimizely/optimizely-sdk": "^4.8.0", + "@optimizely/optimizely-sdk": "^4.9.0", "hoist-non-react-statics": "^3.3.0", "prop-types": "^15.6.2", "utility-types": "^2.1.0 || ^3.0.0" diff --git a/yarn.lock b/yarn.lock index e9b7f245..054cef18 100644 --- a/yarn.lock +++ b/yarn.lock @@ -513,10 +513,10 @@ dependencies: uuid "^3.3.2" -"@optimizely/optimizely-sdk@^4.8.0": - version "4.8.0" - resolved "https://registry.yarnpkg.com/@optimizely/optimizely-sdk/-/optimizely-sdk-4.8.0.tgz#c29e6840342ca56948656c873660b5dcea06aebe" - integrity sha512-x64O4XJG1Xux7i+sBR0kqpQ17AD9XhQ5rhSl0/pdlIBVSyU/HUXwAuFUv9O1ZgnKw6Am2LsLAEckrbkYt6q00g== +"@optimizely/optimizely-sdk@^4.9.0": + version "4.9.0" + resolved "https://registry.yarnpkg.com/@optimizely/optimizely-sdk/-/optimizely-sdk-4.9.0.tgz#18cb98b3150d7e6f949f7f40f277b686f0d01ecc" + integrity sha512-hixUh47DVsZWC7qSsh7zRoOrSipfosW/ihgNjHCMANGR1C1LMcGaRaucTbISKlfz6AFzZgzfcrmEwc1gGHK/Zw== dependencies: "@optimizely/js-sdk-datafile-manager" "^0.9.1" "@optimizely/js-sdk-event-processor" "^0.9.2" From a2567dbe3a52d9a42dcc3efd2abecef209bcd486 Mon Sep 17 00:00:00 2001 From: Zeeshan Ashraf Date: Tue, 18 Jan 2022 20:19:31 -0800 Subject: [PATCH 5/5] updated JS SDK version --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index dcaac90b..c837f6a5 100644 --- a/package.json +++ b/package.json @@ -29,7 +29,7 @@ }, "dependencies": { "@optimizely/js-sdk-logging": "^0.1.0", - "@optimizely/optimizely-sdk": "^4.9.0", + "@optimizely/optimizely-sdk": "^4.9.1", "hoist-non-react-statics": "^3.3.0", "prop-types": "^15.6.2", "utility-types": "^2.1.0 || ^3.0.0" diff --git a/yarn.lock b/yarn.lock index 054cef18..1a8805e5 100644 --- a/yarn.lock +++ b/yarn.lock @@ -513,10 +513,10 @@ dependencies: uuid "^3.3.2" -"@optimizely/optimizely-sdk@^4.9.0": - version "4.9.0" - resolved "https://registry.yarnpkg.com/@optimizely/optimizely-sdk/-/optimizely-sdk-4.9.0.tgz#18cb98b3150d7e6f949f7f40f277b686f0d01ecc" - integrity sha512-hixUh47DVsZWC7qSsh7zRoOrSipfosW/ihgNjHCMANGR1C1LMcGaRaucTbISKlfz6AFzZgzfcrmEwc1gGHK/Zw== +"@optimizely/optimizely-sdk@^4.9.1": + version "4.9.1" + resolved "https://registry.yarnpkg.com/@optimizely/optimizely-sdk/-/optimizely-sdk-4.9.1.tgz#2d9d3f7a18526973e2280c938dbb9cd6ac2c7c88" + integrity sha512-BHwoXONZKOBI2DyXBc8gsYgPgGltCO42/11iFFc4oOnZFkR2UwO7PI2S7oeE2SN168ObTNhmEaJWgenIJuE00A== dependencies: "@optimizely/js-sdk-datafile-manager" "^0.9.1" "@optimizely/js-sdk-event-processor" "^0.9.2"