From 38968b3dd28e6e14adede99ae566b3cc09cff989 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Tue, 19 Jan 2021 10:46:45 -0800 Subject: [PATCH 1/6] WIP: rerender when forced variation change --- README.md | 35 ++++++++-------- dev.df.json | 112 ++++++++++++++++++++++++++++++++++++++++++++++++++ prod.df.json | 77 ++++++++++++++++++++++++++++++++++ src/client.ts | 30 +++++++++++++- src/hooks.ts | 18 ++++++-- 5 files changed, 251 insertions(+), 21 deletions(-) create mode 100644 dev.df.json create mode 100644 prod.df.json diff --git a/README.md b/README.md index c5d3303d..c170f4d6 100644 --- a/README.md +++ b/README.md @@ -321,10 +321,11 @@ _returns_ - `variation : string` - The `activate` return value (variation) for the `experiment` provided. - `clientReady : boolean` - Whether or not the underlying `ReactSDKClient` instance is ready or not. - `didTimeout : boolean` - Whether or not the underlying `ReactSDKClient` became ready within the allowed `timeout` range. + - `forcedVariations : Object` - An object describing any forced variations that have been set on the client via `setForcedVariation`. Top-level keys are user IDs. Values are objects with keys being experiment IDs, and values being variation IDs (the variation that the user is forced into for that experiment).s _Note: `clientReady` can be true even if `didTimeout` is also true. This indicates that the client became ready *after* the timeout period._ -### Render something if feature is enabled +### Example: Set document title based on user's variation ```jsx import { useEffect } from 'react'; @@ -340,7 +341,7 @@ function LoginComponent() { ); useEffect(() => { document.title = variation ? 'login1' : 'login2'; - }, [isEnabled]); + }, [variation]); return (

@@ -375,7 +376,7 @@ _returns_ _Note: `clientReady` can be true even if `didTimeout` is also true. This indicates that the client became ready *after* the timeout period._ -### Render something if feature is enabled +### Example: Render something if feature is enabled ```jsx import { useEffect } from 'react'; @@ -586,60 +587,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/dev.df.json b/dev.df.json new file mode 100644 index 00000000..12e7a9b2 --- /dev/null +++ b/dev.df.json @@ -0,0 +1,112 @@ +{ + "experiments": [], + "featureFlags": [ + { + "id": "3", + "key": "f3", + "rolloutId": "rollout-3-4", + "experimentIds": [], + "variables": [] + }, + { + "id": "2", + "key": "f2", + "rolloutId": "rollout-2-4", + "experimentIds": [], + "variables": [] + }, + { + "id": "1", + "key": "f1", + "rolloutId": "rollout-1-4", + "experimentIds": [], + "variables": [] + } + ], + "rollouts": [ + { + "id": "rollout-3-4", + "experiments": [ + { + "id": "default-rollout-3-4", + "key": "default-rollout-3-4", + "status": "Running", + "layerId": "default-layer-rollout-3-4", + "variations": [ + { + "id": "5", + "key": "off", + "featureEnabled": false, + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "5", + "endOfRange": 10000 + } + ], + "forcedVariations": {}, + "audienceIds": [], + "audienceConditions": [] + } + ] + }, + { + "id": "rollout-2-4", + "experiments": [ + { + "id": "default-rollout-2-4", + "key": "default-rollout-2-4", + "status": "Running", + "layerId": "default-layer-rollout-2-4", + "variations": [ + { + "id": "3", + "key": "off", + "featureEnabled": false, + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "3", + "endOfRange": 10000 + } + ], + "forcedVariations": {}, + "audienceIds": [], + "audienceConditions": [] + } + ] + }, + { + "id": "rollout-1-4", + "experiments": [ + { + "id": "default-rollout-1-4", + "key": "default-rollout-1-4", + "status": "Running", + "layerId": "default-layer-rollout-1-4", + "variations": [ + { + "id": "1", + "key": "off", + "featureEnabled": false, + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "1", + "endOfRange": 10000 + } + ], + "forcedVariations": {}, + "audienceIds": [], + "audienceConditions": [] + } + ] + } + ] +} diff --git a/prod.df.json b/prod.df.json new file mode 100644 index 00000000..2a1cedca --- /dev/null +++ b/prod.df.json @@ -0,0 +1,77 @@ +{ + "experiments": [], + "featureFlags": [ + { + "id": "2", + "key": "f2", + "rolloutId": "rollout-2-3", + "experimentIds": [], + "variables": [] + }, + { + "id": "1", + "key": "f1", + "rolloutId": "rollout-1-3", + "experimentIds": [], + "variables": [] + } + ], + "rollouts": [ + { + "id": "rollout-2-3", + "experiments": [ + { + "id": "default-rollout-2-3", + "key": "default-rollout-2-3", + "status": "Running", + "layerId": "default-layer-rollout-2-3", + "variations": [ + { + "id": "3", + "key": "off", + "featureEnabled": false, + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "3", + "endOfRange": 10000 + } + ], + "forcedVariations": {}, + "audienceIds": [], + "audienceConditions": [] + } + ] + }, + { + "id": "rollout-1-3", + "experiments": [ + { + "id": "default-rollout-1-3", + "key": "default-rollout-1-3", + "status": "Running", + "layerId": "default-layer-rollout-1-3", + "variations": [ + { + "id": "1", + "key": "off", + "featureEnabled": false, + "variables": [] + } + ], + "trafficAllocation": [ + { + "entityId": "1", + "endOfRange": 10000 + } + ], + "forcedVariations": {}, + "audienceIds": [], + "audienceConditions": [] + } + ] + } + ] +} diff --git a/src/client.ts b/src/client.ts index b9fc1b5a..8862d44d 100644 --- a/src/client.ts +++ b/src/client.ts @@ -28,6 +28,12 @@ type DisposeFn = () => void; type OnUserUpdateHandler = (userInfo: UserContext) => void; +export interface ForcedVariations { + readonly [userId: string]: { readonly [experimentId: string]: string }; +} + +type OnForcedVariationsUpdateHandler = (forcedVariations: ForcedVariations) => void; + export type OnReadyResult = { success: boolean; reason?: string; @@ -131,6 +137,10 @@ 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; + + getForcedVariations(): ForcedVariations; } type UserContext = { @@ -150,6 +160,7 @@ class OptimizelyReactSDKClient implements ReactSDKClient { private userPromise: Promise; private isUserPromiseResolved = false; private onUserUpdateHandlers: OnUserUpdateHandler[] = []; + private onForcedVariationsUpdateHandlers: OnForcedVariationsUpdateHandler[] = []; private readonly _client: optimizely.Client; @@ -237,6 +248,17 @@ class OptimizelyReactSDKClient implements ReactSDKClient { }; } + 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,13 @@ 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(this.getForcedVariations())); + return result; + } + + public getForcedVariations(): ForcedVariations { + return (this._client as any).decisionService.forcedVariationMap; } /** diff --git a/src/hooks.ts b/src/hooks.ts index 29665ff1..51a8bc68 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -19,7 +19,7 @@ import { UserAttributes } from '@optimizely/optimizely-sdk'; import { getLogger, LoggerFacade } from '@optimizely/js-sdk-logging'; import { setupAutoUpdateListeners } from './autoUpdate'; -import { ReactSDKClient, VariableValuesObject, OnReadyResult } from './client'; +import { ReactSDKClient, VariableValuesObject, OnReadyResult, ForcedVariations } from './client'; import { OptimizelyContext } from './Context'; import { areAttributesEqual } from './utils'; @@ -64,7 +64,8 @@ interface UseExperiment { (experimentKey: string, options?: HookOptions, overrides?: HookOverrides): [ ExperimentDecisionValues['variation'], ClientReady, - DidTimeout + DidTimeout, + ForcedVariations ]; } @@ -222,7 +223,18 @@ export const useExperiment: UseExperiment = (experimentKey, options = {}, overri return (): void => {}; }, [isClientReady, options.autoUpdate, optimizely, experimentKey, getCurrentDecision]); - return [state.variation, state.clientReady, state.didTimeout]; + const [forcedVariations, setForcedVariations] = useState(() => optimizely.getForcedVariations()); + useEffect( + () => + optimizely.onForcedVariationsUpdate((newForcedVariations: ForcedVariations) => + // Using the spread operator to pass a new object to every setState call. We want to trigger a + // re-render every time. + setForcedVariations({ ...newForcedVariations }) + ), + [optimizely] + ); + + return [state.variation, state.clientReady, state.didTimeout, forcedVariations]; }; /** From a04ccfebd13f78e6f9caae7d7e3425413a9d9bf5 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Thu, 21 Jan 2021 11:35:15 -0800 Subject: [PATCH 2/6] A different approach --- README.md | 2 +- src/client.ts | 47 ++++++++++++++++++++++++++++++++++++++++------- src/hooks.ts | 16 ++++++++-------- 3 files changed, 49 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index c170f4d6..3bbc2e47 100644 --- a/README.md +++ b/README.md @@ -321,7 +321,7 @@ _returns_ - `variation : string` - The `activate` return value (variation) for the `experiment` provided. - `clientReady : boolean` - Whether or not the underlying `ReactSDKClient` instance is ready or not. - `didTimeout : boolean` - Whether or not the underlying `ReactSDKClient` became ready within the allowed `timeout` range. - - `forcedVariations : Object` - An object describing any forced variations that have been set on the client via `setForcedVariation`. Top-level keys are user IDs. Values are objects with keys being experiment IDs, and values being variation IDs (the variation that the user is forced into for that experiment).s + - `forcedVariationsForUser : Object` - An object describing any forced variations that have been set on the client via `setForcedVariation`, for the current user. The object's keys are experiment keys, and values are variation keys that have been forced for that experiment for the current user. _Note: `clientReady` can be true even if `didTimeout` is also true. This indicates that the client became ready *after* the timeout period._ diff --git a/src/client.ts b/src/client.ts index 8862d44d..fc977db6 100644 --- a/src/client.ts +++ b/src/client.ts @@ -28,11 +28,11 @@ type DisposeFn = () => void; type OnUserUpdateHandler = (userInfo: UserContext) => void; -export interface ForcedVariations { - readonly [userId: string]: { readonly [experimentId: string]: string }; +export interface ForcedVariationsForUser { + [experimentId: string]: string; } -type OnForcedVariationsUpdateHandler = (forcedVariations: ForcedVariations) => void; +type OnForcedVariationsUpdateHandler = () => void; export type OnReadyResult = { success: boolean; @@ -140,7 +140,7 @@ export interface ReactSDKClient extends optimizely.Client { onForcedVariationsUpdate(handler: OnForcedVariationsUpdateHandler): DisposeFn; - getForcedVariations(): ForcedVariations; + getForcedVariations(overrideUserId?: string): ForcedVariationsForUser; } type UserContext = { @@ -248,6 +248,12 @@ 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); @@ -617,12 +623,39 @@ class OptimizelyReactSDKClient implements ReactSDKClient { return false; } const result = this._client.setForcedVariation(experiment, finalUserId, finalVariationKey); - this.onForcedVariationsUpdateHandlers.forEach(handler => handler(this.getForcedVariations())); + this.onForcedVariationsUpdateHandlers.forEach(handler => handler()); return result; } - public getForcedVariations(): ForcedVariations { - return (this._client as any).decisionService.forcedVariationMap; + /** + * Gets the forced variation for the current user, or argument override user, + * and all experiments listed in the current OptimizelyConfig object. + * @param {string} [overrideUserId] + * @returns {ForcedVariationsForUser} + * @memberof OptimizelyReactSDKClient + */ + public getForcedVariations(overrideUserId?: string): ForcedVariationsForUser { + const forcedVariations: ForcedVariationsForUser = {}; + + const optlyConfig = this._client.getOptimizelyConfig(); + if (!optlyConfig) { + return forcedVariations; + } + + const user = this.getUserContextWithOverrides(overrideUserId); + const userId = user.id; + if (userId === null) { + return forcedVariations; + } + + Object.keys(optlyConfig.experimentsMap).forEach(expKey => { + const forcedVariation = this._client.getForcedVariation(expKey, userId); + if (forcedVariation !== null) { + forcedVariations[expKey] = forcedVariation; + } + }); + + return forcedVariations; } /** diff --git a/src/hooks.ts b/src/hooks.ts index 51a8bc68..93054d8a 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -19,7 +19,7 @@ import { UserAttributes } from '@optimizely/optimizely-sdk'; import { getLogger, LoggerFacade } from '@optimizely/js-sdk-logging'; import { setupAutoUpdateListeners } from './autoUpdate'; -import { ReactSDKClient, VariableValuesObject, OnReadyResult, ForcedVariations } from './client'; +import { ReactSDKClient, VariableValuesObject, OnReadyResult, ForcedVariationsForUser } from './client'; import { OptimizelyContext } from './Context'; import { areAttributesEqual } from './utils'; @@ -65,7 +65,7 @@ interface UseExperiment { ExperimentDecisionValues['variation'], ClientReady, DidTimeout, - ForcedVariations + ForcedVariationsForUser ]; } @@ -223,15 +223,15 @@ export const useExperiment: UseExperiment = (experimentKey, options = {}, overri return (): void => {}; }, [isClientReady, options.autoUpdate, optimizely, experimentKey, getCurrentDecision]); - const [forcedVariations, setForcedVariations] = useState(() => optimizely.getForcedVariations()); + const [forcedVariations, setForcedVariations] = useState(() => + optimizely.getForcedVariations(overrides.overrideUserId) + ); useEffect( () => - optimizely.onForcedVariationsUpdate((newForcedVariations: ForcedVariations) => - // Using the spread operator to pass a new object to every setState call. We want to trigger a - // re-render every time. - setForcedVariations({ ...newForcedVariations }) + optimizely.onForcedVariationsUpdate(() => + setForcedVariations(optimizely.getForcedVariations(overrides.overrideUserId)) ), - [optimizely] + [optimizely, overrides.overrideUserId] ); return [state.variation, state.clientReady, state.didTimeout, forcedVariations]; From bef461effa9923d9ba81e6b516a31300bd9f82d7 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Thu, 21 Jan 2021 15:38:12 -0800 Subject: [PATCH 3/6] tests, simplify --- src/Experiment.spec.tsx | 1 + src/client.spec.ts | 28 ++++++++++++++++++++++++++++ src/client.ts | 37 ------------------------------------- src/hooks.spec.tsx | 25 +++++++++++++++++++++++++ src/hooks.ts | 21 ++++++++++----------- 5 files changed, 64 insertions(+), 48 deletions(-) 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 fc977db6..fad2162c 100644 --- a/src/client.ts +++ b/src/client.ts @@ -28,10 +28,6 @@ type DisposeFn = () => void; type OnUserUpdateHandler = (userInfo: UserContext) => void; -export interface ForcedVariationsForUser { - [experimentId: string]: string; -} - type OnForcedVariationsUpdateHandler = () => void; export type OnReadyResult = { @@ -139,8 +135,6 @@ export interface ReactSDKClient extends optimizely.Client { getForcedVariation(experiment: string, overrideUserId?: string): string | null; onForcedVariationsUpdate(handler: OnForcedVariationsUpdateHandler): DisposeFn; - - getForcedVariations(overrideUserId?: string): ForcedVariationsForUser; } type UserContext = { @@ -627,37 +621,6 @@ class OptimizelyReactSDKClient implements ReactSDKClient { return result; } - /** - * Gets the forced variation for the current user, or argument override user, - * and all experiments listed in the current OptimizelyConfig object. - * @param {string} [overrideUserId] - * @returns {ForcedVariationsForUser} - * @memberof OptimizelyReactSDKClient - */ - public getForcedVariations(overrideUserId?: string): ForcedVariationsForUser { - const forcedVariations: ForcedVariationsForUser = {}; - - const optlyConfig = this._client.getOptimizelyConfig(); - if (!optlyConfig) { - return forcedVariations; - } - - const user = this.getUserContextWithOverrides(overrideUserId); - const userId = user.id; - if (userId === null) { - return forcedVariations; - } - - Object.keys(optlyConfig.experimentsMap).forEach(expKey => { - const forcedVariation = this._client.getForcedVariation(expKey, userId); - if (forcedVariation !== null) { - forcedVariations[expKey] = forcedVariation; - } - }); - - return forcedVariations; - } - /** * Returns OptimizelyConfig object containing experiments and features data * @returns {optimizely.OptimizelyConfig | null} optimizely config diff --git a/src/hooks.spec.tsx b/src/hooks.spec.tsx index 3cde2e73..d1fc4db3 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.only('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 93054d8a..b8b4ff6e 100644 --- a/src/hooks.ts +++ b/src/hooks.ts @@ -19,7 +19,7 @@ import { UserAttributes } from '@optimizely/optimizely-sdk'; import { getLogger, LoggerFacade } from '@optimizely/js-sdk-logging'; import { setupAutoUpdateListeners } from './autoUpdate'; -import { ReactSDKClient, VariableValuesObject, OnReadyResult, ForcedVariationsForUser } from './client'; +import { ReactSDKClient, VariableValuesObject, OnReadyResult } from './client'; import { OptimizelyContext } from './Context'; import { areAttributesEqual } from './utils'; @@ -64,8 +64,7 @@ interface UseExperiment { (experimentKey: string, options?: HookOptions, overrides?: HookOverrides): [ ExperimentDecisionValues['variation'], ClientReady, - DidTimeout, - ForcedVariationsForUser + DidTimeout ]; } @@ -223,18 +222,18 @@ export const useExperiment: UseExperiment = (experimentKey, options = {}, overri return (): void => {}; }, [isClientReady, options.autoUpdate, optimizely, experimentKey, getCurrentDecision]); - const [forcedVariations, setForcedVariations] = useState(() => - optimizely.getForcedVariations(overrides.overrideUserId) - ); useEffect( () => - optimizely.onForcedVariationsUpdate(() => - setForcedVariations(optimizely.getForcedVariations(overrides.overrideUserId)) - ), - [optimizely, overrides.overrideUserId] + optimizely.onForcedVariationsUpdate(() => { + setState(prevState => ({ + ...prevState, + ...getCurrentDecision(), + })); + }), + [getCurrentDecision, optimizely] ); - return [state.variation, state.clientReady, state.didTimeout, forcedVariations]; + return [state.variation, state.clientReady, state.didTimeout]; }; /** From ef97708332ed81bc0550d8f037fe05c15ce56eb1 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Thu, 21 Jan 2021 15:46:30 -0800 Subject: [PATCH 4/6] Fix README, test --- README.md | 35 +++++++++++++++++------------------ src/hooks.spec.tsx | 2 +- 2 files changed, 18 insertions(+), 19 deletions(-) diff --git a/README.md b/README.md index 3bbc2e47..c5d3303d 100644 --- a/README.md +++ b/README.md @@ -321,11 +321,10 @@ _returns_ - `variation : string` - The `activate` return value (variation) for the `experiment` provided. - `clientReady : boolean` - Whether or not the underlying `ReactSDKClient` instance is ready or not. - `didTimeout : boolean` - Whether or not the underlying `ReactSDKClient` became ready within the allowed `timeout` range. - - `forcedVariationsForUser : Object` - An object describing any forced variations that have been set on the client via `setForcedVariation`, for the current user. The object's keys are experiment keys, and values are variation keys that have been forced for that experiment for the current user. _Note: `clientReady` can be true even if `didTimeout` is also true. This indicates that the client became ready *after* the timeout period._ -### Example: Set document title based on user's variation +### Render something if feature is enabled ```jsx import { useEffect } from 'react'; @@ -341,7 +340,7 @@ function LoginComponent() { ); useEffect(() => { document.title = variation ? 'login1' : 'login2'; - }, [variation]); + }, [isEnabled]); return (

@@ -376,7 +375,7 @@ _returns_ _Note: `clientReady` can be true even if `didTimeout` is also true. This indicates that the client became ready *after* the timeout period._ -### Example: Render something if feature is enabled +### Render something if feature is enabled ```jsx import { useEffect } from 'react'; @@ -587,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/hooks.spec.tsx b/src/hooks.spec.tsx index d1fc4db3..9d45747c 100644 --- a/src/hooks.spec.tsx +++ b/src/hooks.spec.tsx @@ -367,7 +367,7 @@ describe('hooks', () => { expect(activateMock).not.toHaveBeenCalled(); }); - it.only('should re-render after setForcedVariation is called on the client', async () => { + it('should re-render after setForcedVariation is called on the client', async () => { activateMock.mockReturnValue(null); const component = Enzyme.mount( From e251de8e3ac89d8f891c3c143a7728f6a3b406b0 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Thu, 21 Jan 2021 16:12:49 -0800 Subject: [PATCH 5/6] Remove datafiles --- dev.df.json | 112 --------------------------------------------------- prod.df.json | 77 ----------------------------------- 2 files changed, 189 deletions(-) delete mode 100644 dev.df.json delete mode 100644 prod.df.json diff --git a/dev.df.json b/dev.df.json deleted file mode 100644 index 12e7a9b2..00000000 --- a/dev.df.json +++ /dev/null @@ -1,112 +0,0 @@ -{ - "experiments": [], - "featureFlags": [ - { - "id": "3", - "key": "f3", - "rolloutId": "rollout-3-4", - "experimentIds": [], - "variables": [] - }, - { - "id": "2", - "key": "f2", - "rolloutId": "rollout-2-4", - "experimentIds": [], - "variables": [] - }, - { - "id": "1", - "key": "f1", - "rolloutId": "rollout-1-4", - "experimentIds": [], - "variables": [] - } - ], - "rollouts": [ - { - "id": "rollout-3-4", - "experiments": [ - { - "id": "default-rollout-3-4", - "key": "default-rollout-3-4", - "status": "Running", - "layerId": "default-layer-rollout-3-4", - "variations": [ - { - "id": "5", - "key": "off", - "featureEnabled": false, - "variables": [] - } - ], - "trafficAllocation": [ - { - "entityId": "5", - "endOfRange": 10000 - } - ], - "forcedVariations": {}, - "audienceIds": [], - "audienceConditions": [] - } - ] - }, - { - "id": "rollout-2-4", - "experiments": [ - { - "id": "default-rollout-2-4", - "key": "default-rollout-2-4", - "status": "Running", - "layerId": "default-layer-rollout-2-4", - "variations": [ - { - "id": "3", - "key": "off", - "featureEnabled": false, - "variables": [] - } - ], - "trafficAllocation": [ - { - "entityId": "3", - "endOfRange": 10000 - } - ], - "forcedVariations": {}, - "audienceIds": [], - "audienceConditions": [] - } - ] - }, - { - "id": "rollout-1-4", - "experiments": [ - { - "id": "default-rollout-1-4", - "key": "default-rollout-1-4", - "status": "Running", - "layerId": "default-layer-rollout-1-4", - "variations": [ - { - "id": "1", - "key": "off", - "featureEnabled": false, - "variables": [] - } - ], - "trafficAllocation": [ - { - "entityId": "1", - "endOfRange": 10000 - } - ], - "forcedVariations": {}, - "audienceIds": [], - "audienceConditions": [] - } - ] - } - ] -} diff --git a/prod.df.json b/prod.df.json deleted file mode 100644 index 2a1cedca..00000000 --- a/prod.df.json +++ /dev/null @@ -1,77 +0,0 @@ -{ - "experiments": [], - "featureFlags": [ - { - "id": "2", - "key": "f2", - "rolloutId": "rollout-2-3", - "experimentIds": [], - "variables": [] - }, - { - "id": "1", - "key": "f1", - "rolloutId": "rollout-1-3", - "experimentIds": [], - "variables": [] - } - ], - "rollouts": [ - { - "id": "rollout-2-3", - "experiments": [ - { - "id": "default-rollout-2-3", - "key": "default-rollout-2-3", - "status": "Running", - "layerId": "default-layer-rollout-2-3", - "variations": [ - { - "id": "3", - "key": "off", - "featureEnabled": false, - "variables": [] - } - ], - "trafficAllocation": [ - { - "entityId": "3", - "endOfRange": 10000 - } - ], - "forcedVariations": {}, - "audienceIds": [], - "audienceConditions": [] - } - ] - }, - { - "id": "rollout-1-3", - "experiments": [ - { - "id": "default-rollout-1-3", - "key": "default-rollout-1-3", - "status": "Running", - "layerId": "default-layer-rollout-1-3", - "variations": [ - { - "id": "1", - "key": "off", - "featureEnabled": false, - "variables": [] - } - ], - "trafficAllocation": [ - { - "entityId": "1", - "endOfRange": 10000 - } - ], - "forcedVariations": {}, - "audienceIds": [], - "audienceConditions": [] - } - ] - } - ] -} From 5526a33c0c97de7c87139908e98cc76e49309b69 Mon Sep 17 00:00:00 2001 From: Matt Carroll Date: Thu, 21 Jan 2021 16:29:26 -0800 Subject: [PATCH 6/6] Add note to README --- README.md | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) 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)