diff --git a/README.md b/README.md index c5d3303d..12124bac 100644 --- a/README.md +++ b/README.md @@ -491,7 +491,7 @@ The following type definitions are used in the `ReactSDKClient` interface: - `isFeatureEnabled(featureKey: string, overrideUserId?: string, overrideAttributes?: UserAttributes): boolean` Return the enabled status for the given feature and user - `getEnabledFeatures(overrideUserId?: string, overrideAttributes?: UserAttributes): Array`: Return the keys of all features enabled for the given user - `track(eventKey: string, overrideUserId?: string | EventTags, overrideAttributes?: UserAttributes, eventTags?: EventTags): void` Track an event to the Optimizely results backend -- `setForcedVariation(experiment: string, overrideUserIdOrVariationKey: string, variationKey?: string | null): boolean` Set a forced variation for the given experiment, variation, and user +- `setForcedVariation(experiment: string, overrideUserIdOrVariationKey: string, variationKey?: string | null): boolean` Set a forced variation for the given experiment, variation, and user. **Note**: calling `setForcedVariation` on a given client will trigger a re-render of all `useExperiment` hooks and `OptimizelyExperiment` components that are using that client. - `getForcedVariation(experiment: string, overrideUserId?: string): string | null` Get the forced variation for the given experiment, variation, and user ## Rollout or experiment a feature user-by-user @@ -586,60 +586,60 @@ First-party code subject to copyrights held by Optimizely, Inc. and its contribu This repository includes the following third party open source code: -[**hoist-non-react-statics**](https://github.com/mridgway/hoist-non-react-statics) +[**hoist-non-react-statics**](https://github.com/mridgway/hoist-non-react-statics) Copyright © 2015 Yahoo!, Inc. License: [BSD](https://github.com/mridgway/hoist-non-react-statics/blob/master/LICENSE.md) -[**js-tokens**](https://github.com/lydell/js-tokens) +[**js-tokens**](https://github.com/lydell/js-tokens) Copyright © 2014, 2015, 2016, 2017, 2018, 2019 Simon Lydell License: [MIT](https://github.com/lydell/js-tokens/blob/master/LICENSE) -[**json-schema**](https://github.com/kriszyp/json-schema) +[**json-schema**](https://github.com/kriszyp/json-schema) Copyright © 2005-2015, The Dojo Foundation License: [BSD](https://github.com/kriszyp/json-schema/blob/master/LICENSE) -[**lodash**](https://github.com/lodash/lodash/) +[**lodash**](https://github.com/lodash/lodash/) Copyright © JS Foundation and other contributors License: [MIT](https://github.com/lodash/lodash/blob/master/LICENSE) -[**loose-envify**](https://github.com/zertosh/loose-envify) +[**loose-envify**](https://github.com/zertosh/loose-envify) Copyright © 2015 Andres Suarez License: [MIT](https://github.com/zertosh/loose-envify/blob/master/LICENSE) -[**node-murmurhash**](https://github.com/perezd/node-murmurhash) +[**node-murmurhash**](https://github.com/perezd/node-murmurhash) Copyright © 2012 Gary Court, Derek Perez License: [MIT](https://github.com/perezd/node-murmurhash/blob/master/README.md) -[**object-assign**](https://github.com/sindresorhus/object-assign) +[**object-assign**](https://github.com/sindresorhus/object-assign) Copyright © Sindre Sorhus (sindresorhus.com) License: [MIT](https://github.com/sindresorhus/object-assign/blob/master/license) -[**promise-polyfill**](https://github.com/taylorhakes/promise-polyfill) +[**promise-polyfill**](https://github.com/taylorhakes/promise-polyfill) Copyright © 2014 Taylor Hakes Copyright © 2014 Forbes Lindesay License: [MIT](https://github.com/taylorhakes/promise-polyfill/blob/master/LICENSE) -[**prop-types**](https://github.com/facebook/prop-types) +[**prop-types**](https://github.com/facebook/prop-types) Copyright © 2013-present, Facebook, Inc. License: [MIT](https://github.com/facebook/prop-types/blob/master/LICENSE) -[**react-is**](https://github.com/facebook/react) +[**react-is**](https://github.com/facebook/react) Copyright © Facebook, Inc. and its affiliates. License: [MIT](https://github.com/facebook/react/blob/master/LICENSE) -[**react**](https://github.com/facebook/react) +[**react**](https://github.com/facebook/react) Copyright © Facebook, Inc. and its affiliates. License: [MIT](https://github.com/facebook/react/blob/master/LICENSE) -[**scheduler**](https://github.com/facebook/react) +[**scheduler**](https://github.com/facebook/react) Copyright © Facebook, Inc. and its affiliates. License: [MIT](https://github.com/facebook/react/blob/master/LICENSE) -[**utility-types**](https://github.com/piotrwitek/utility-types) +[**utility-types**](https://github.com/piotrwitek/utility-types) Copyright © 2016 Piotr Witek License: [MIT](https://github.com/piotrwitek/utility-types/blob/master/LICENSE) -[**node-uuid**](https://github.com/kelektiv/node-uuid) +[**node-uuid**](https://github.com/kelektiv/node-uuid) Copyright © 2010-2016 Robert Kieffer and other contributors License: [MIT](https://github.com/kelektiv/node-uuid/blob/master/LICENSE.md) diff --git a/src/Experiment.spec.tsx b/src/Experiment.spec.tsx index fee0ce80..ae592202 100644 --- a/src/Experiment.spec.tsx +++ b/src/Experiment.spec.tsx @@ -52,6 +52,7 @@ describe('', () => { attributes: {}, }, isReady: jest.fn().mockReturnValue(false), + onForcedVariationsUpdate: jest.fn().mockReturnValue(() => {}), } as unknown) as ReactSDKClient; }); diff --git a/src/client.spec.ts b/src/client.spec.ts index 7f786573..5917ee02 100644 --- a/src/client.spec.ts +++ b/src/client.spec.ts @@ -635,4 +635,32 @@ describe('ReactSDKClient', () => { }); }); }); + + describe('onForcedVariationsUpdate', () => { + let instance: ReactSDKClient; + beforeEach(() => { + instance = createInstance(config); + instance.setUser({ + id: 'xxfueaojfe8&86', + attributes: { + foo: 'bar', + }, + }); + }); + + it('calls the handler function when setForcedVariation is called', () => { + const handler = jest.fn(); + instance.onForcedVariationsUpdate(handler); + instance.setForcedVariation('my_exp', 'xxfueaojfe8&86', 'variation_a'); + expect(handler).toBeCalledTimes(1); + }); + + it('removes the handler when the cleanup fn is called', () => { + const handler = jest.fn(); + const cleanup = instance.onForcedVariationsUpdate(handler); + cleanup(); + instance.setForcedVariation('my_exp', 'xxfueaojfe8&86', 'variation_a'); + expect(handler).not.toBeCalled(); + }); + }); }); diff --git a/src/client.ts b/src/client.ts index b9fc1b5a..fad2162c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -28,6 +28,8 @@ type DisposeFn = () => void; type OnUserUpdateHandler = (userInfo: UserContext) => void; +type OnForcedVariationsUpdateHandler = () => void; + export type OnReadyResult = { success: boolean; reason?: string; @@ -131,6 +133,8 @@ export interface ReactSDKClient extends optimizely.Client { setForcedVariation(experiment: string, overrideUserIdOrVariationKey: string, variationKey?: string | null): boolean; getForcedVariation(experiment: string, overrideUserId?: string): string | null; + + onForcedVariationsUpdate(handler: OnForcedVariationsUpdateHandler): DisposeFn; } type UserContext = { @@ -150,6 +154,7 @@ class OptimizelyReactSDKClient implements ReactSDKClient { private userPromise: Promise; private isUserPromiseResolved = false; private onUserUpdateHandlers: OnUserUpdateHandler[] = []; + private onForcedVariationsUpdateHandlers: OnForcedVariationsUpdateHandler[] = []; private readonly _client: optimizely.Client; @@ -237,6 +242,23 @@ class OptimizelyReactSDKClient implements ReactSDKClient { }; } + /** + * Register a handler to be called whenever setForcedVariation is called on + * this client. Returns a function that un-registers the handler when called. + * @param {OnForcedVariationsUpdateHandler} handler + * @returns {DisposeFn} + */ + onForcedVariationsUpdate(handler: OnForcedVariationsUpdateHandler): DisposeFn { + this.onForcedVariationsUpdateHandlers.push(handler); + + return (): void => { + const ind = this.onForcedVariationsUpdateHandlers.indexOf(handler); + if (ind > -1) { + this.onForcedVariationsUpdateHandlers.splice(ind, 1); + } + }; + } + isReady(): boolean { return this.dataReadyPromiseFulfilled; } @@ -594,7 +616,9 @@ class OptimizelyReactSDKClient implements ReactSDKClient { if (finalUserId === null) { return false; } - return this._client.setForcedVariation(experiment, finalUserId, finalVariationKey); + const result = this._client.setForcedVariation(experiment, finalUserId, finalVariationKey); + this.onForcedVariationsUpdateHandlers.forEach(handler => handler()); + return result; } /** diff --git a/src/hooks.spec.tsx b/src/hooks.spec.tsx index 3cde2e73..9d45747c 100644 --- a/src/hooks.spec.tsx +++ b/src/hooks.spec.tsx @@ -51,6 +51,7 @@ describe('hooks', () => { let UseExperimentLoggingComponent: React.FunctionComponent; let UseFeatureLoggingComponent: React.FunctionComponent; let mockLog: jest.Mock; + let forcedVariationUpdateCallbacks: Array<() => void>; beforeEach(() => { getOnReadyPromise = ({ timeout = 0 }: any): Promise => @@ -76,6 +77,7 @@ describe('hooks', () => { mockDelay = 10; readySuccess = true; notificationListenerCallbacks = []; + forcedVariationUpdateCallbacks = []; optimizelyMock = ({ activate: activateMock, @@ -97,6 +99,11 @@ describe('hooks', () => { attributes: {}, }, isReady: () => readySuccess, + onForcedVariationsUpdate: jest.fn().mockImplementation(handler => { + forcedVariationUpdateCallbacks.push(handler); + return () => {}; + }), + getForcedVariations: jest.fn().mockReturnValue({}), } as unknown) as ReactSDKClient; mockLog = jest.fn(); @@ -359,6 +366,24 @@ describe('hooks', () => { component.update(); expect(activateMock).not.toHaveBeenCalled(); }); + + it('should re-render after setForcedVariation is called on the client', async () => { + activateMock.mockReturnValue(null); + const component = Enzyme.mount( + + + + ); + + component.update(); + expect(component.text()).toBe('null|true|false'); + + activateMock.mockReturnValue('12345'); + forcedVariationUpdateCallbacks[0](); + + component.update(); + expect(component.text()).toBe('12345|true|false'); + }); }); describe('useFeature', () => { diff --git a/src/hooks.ts b/src/hooks.ts index 29665ff1..b8b4ff6e 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -222,6 +222,17 @@ export const useExperiment: UseExperiment = (experimentKey, options = {}, overri return (): void => {}; }, [isClientReady, options.autoUpdate, optimizely, experimentKey, getCurrentDecision]); + useEffect( + () => + optimizely.onForcedVariationsUpdate(() => { + setState(prevState => ({ + ...prevState, + ...getCurrentDecision(), + })); + }), + [getCurrentDecision, optimizely] + ); + return [state.variation, state.clientReady, state.didTimeout]; };