diff --git a/package.json b/package.json index 3314fb36..c837f6a5 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.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/src/client.spec.ts b/src/client.spec.ts index 1e45c9ca..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. @@ -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 02f01eff..20b40761 100644 --- a/src/client.ts +++ b/src/client.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. @@ -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, areUsersEqual } from './utils'; +import { notifier } from './notifier'; const logger = logging.getLogger('ReactSDK'); @@ -143,35 +145,46 @@ export interface ReactSDKClient extends Omit void; private userPromise: Promise; private isUserPromiseResolved = false; private onUserUpdateHandlers: OnUserUpdateHandler[] = []; private onForcedVariationsUpdateHandlers: OnForcedVariationsUpdateHandler[] = []; + private forcedDecisionFlagKeys: Set = new Set(); // Is the javascript SDK instance ready. private isClientReady: boolean = false; @@ -213,7 +226,7 @@ class OptimizelyReactSDKClient implements ReactSDKClient { this.userPromiseResolver = resolve; }).then(() => { this.isUserReady = true; - return { success: true } + return { success: true }; }); this._client.onReady().then(() => { @@ -221,7 +234,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 +243,7 @@ class OptimizelyReactSDKClient implements ReactSDKClient { }); } - getIsReadyPromiseFulfilled(): boolean { + getIsReadyPromiseFulfilled(): boolean { return this.isReadyPromiseFulfilled; } @@ -263,19 +275,49 @@ class OptimizelyReactSDKClient implements ReactSDKClient { }); } + getUserContextInstance(userInfo: UserInfo): optimizely.OptimizelyUserContext | null { + let userContext: optimizely.OptimizelyUserContext | null = null; + + if (this.userContext) { + if (areUsersEqual(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; } + if (!this.isUserPromiseResolved) { this.userPromiseResolver(this.user); this.isUserPromiseResolved = true; } + this.onUserUpdateHandlers.forEach(handler => handler(this.user)); } @@ -338,21 +380,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 +410,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 +440,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 +511,88 @@ 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 isSuccess = this.userContext.setForcedDecision(decisionContext, decision); + + if (isSuccess) { + this.forcedDecisionFlagKeys.add(decisionContext.flagKey); + notifier.notify(decisionContext.flagKey); + } + } + + /** + * 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 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 isSuccess = this.userContext.removeAllForcedDecisions(); + + if (isSuccess) { + this.forcedDecisionFlagKeys.forEach(flagKey => notifier.notify(flagKey)); + this.forcedDecisionFlagKeys.clear(); + } + + return isSuccess; + } + /** * Returns true if the feature is enabled for the given user * @param {string} feature diff --git a/src/hooks.spec.tsx b/src/hooks.spec.tsx index fb15c7a2..8bcde621 100644 --- a/src/hooks.spec.tsx +++ b/src/hooks.spec.tsx @@ -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. @@ -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 5ded0495..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. @@ -20,6 +20,7 @@ import { getLogger, LoggerFacade } from '@optimizely/js-sdk-logging'; import { setupAutoUpdateListeners } from './autoUpdate'; import { ReactSDKClient, VariableValuesObject, OnReadyResult } from './client'; +import { notifier } from './notifier'; import { OptimizelyContext } from './Context'; import { areAttributesEqual, OptimizelyDecision, createFailedDecision } from './utils'; @@ -348,17 +349,20 @@ export const useDecision: UseDecision = (flagKey, options = {}, overrides = {}) } 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 +392,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 +404,20 @@ export const useDecision: UseDecision = (flagKey, options = {}, overrides = {}) } }, []); + useEffect(() => { + 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. if (optimizely.getIsReadyPromiseFulfilled() && options.autoUpdate) { diff --git a/src/notifier.spec.ts b/src/notifier.spec.ts new file mode 100644 index 00000000..87a9e7a1 --- /dev/null +++ b/src/notifier.spec.ts @@ -0,0 +1,139 @@ +/** + * 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. + * 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..a064da65 --- /dev/null +++ b/src/notifier.ts @@ -0,0 +1,52 @@ +/** + * 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. + * 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/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, + }, + }; } diff --git a/yarn.lock b/yarn.lock index e92f3166..1a8805e5 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.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.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"