Skip to content

Commit

Permalink
POC: Implementing an observer memoize and listen to changes in the op…
Browse files Browse the repository at this point in the history
…timizely client.
  • Loading branch information
cristianparcu-epi committed Nov 12, 2021
1 parent 2731ec8 commit 6a39b87
Show file tree
Hide file tree
Showing 3 changed files with 217 additions and 41 deletions.
162 changes: 131 additions & 31 deletions src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,9 @@

import * as optimizely from '@optimizely/optimizely-sdk';
import * as logging from '@optimizely/js-sdk-logging';

import { OptimizelyDecision, UserInfo, createFailedDecision } from './utils';
import clientStore from './store';

const logger = logging.getLogger('ReactSDK');

Expand Down Expand Up @@ -143,30 +145,31 @@ export interface ReactSDKClient extends Omit<optimizely.Client, 'createUserConte
options?: optimizely.OptimizelyDecideOption[],
overrideUserId?: string,
overrideAttributes?: optimizely.UserAttributes
): OptimizelyDecision
): OptimizelyDecision;

decideAll(
options?: optimizely.OptimizelyDecideOption[],
overrideUserId?: string,
overrideAttributes?: optimizely.UserAttributes
): { [key: string]: OptimizelyDecision }
): { [key: string]: OptimizelyDecision };

decideForKeys(
keys: string[],
options?: optimizely.OptimizelyDecideOption[],
overrideUserId?: string,
overrideAttributes?: optimizely.UserAttributes
): { [key: string]: OptimizelyDecision }
): { [key: string]: OptimizelyDecision };
}

export const DEFAULT_ON_READY_TIMEOUT = 5000;

class OptimizelyReactSDKClient implements ReactSDKClient {
public initialConfig: optimizely.Config;
public user: UserInfo = {
public user: UserInfo = {
id: null,
attributes: {},
};
private userContext: optimizely.OptimizelyUserContext | null = null;
private userPromiseResolver: (user: UserInfo) => void;
private userPromise: Promise<OnReadyResult>;
private isUserPromiseResolved = false;
Expand Down Expand Up @@ -213,15 +216,14 @@ class OptimizelyReactSDKClient implements ReactSDKClient {
this.userPromiseResolver = resolve;
}).then(() => {
this.isUserReady = true;
return { success: true }
return { success: true };
});

this._client.onReady().then(() => {
this.isClientReady = true;
});

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 {
Expand All @@ -231,7 +233,7 @@ class OptimizelyReactSDKClient implements ReactSDKClient {
});
}

getIsReadyPromiseFulfilled(): boolean {
getIsReadyPromiseFulfilled(): boolean {
return this.isReadyPromiseFulfilled;
}

Expand Down Expand Up @@ -263,6 +265,34 @@ class OptimizelyReactSDKClient implements ReactSDKClient {
});
}

getUserContextInstance(userInfo: UserInfo): optimizely.OptimizelyUserContext | null {
let userContext: optimizely.OptimizelyUserContext | null = null;

const userId = userInfo.id || this.user.id;
const userAttributes = userInfo.attributes || this.user.attributes;

if (this.userContext) {
if (JSON.stringify(userInfo) === JSON.stringify(this.user)) {
return this.userContext;
}

if (userId) {
userContext = this._client.createUserContext(userId, userAttributes);
return userContext;
}

return null;
}

if (userId) {
const userContext = this._client.createUserContext(userId, userAttributes);
this.userContext = userContext;
return this.userContext;
}

return null;
}

setUser(userInfo: UserInfo): void {
// TODO add check for valid user
if (userInfo.id) {
Expand All @@ -276,6 +306,8 @@ class OptimizelyReactSDKClient implements ReactSDKClient {
this.userPromiseResolver(this.user);
this.isUserPromiseResolved = true;
}

this.userContext = this.getUserContextInstance(userInfo);
this.onUserUpdateHandlers.forEach(handler => handler(this.user));
}

Expand Down Expand Up @@ -338,21 +370,22 @@ class OptimizelyReactSDKClient implements ReactSDKClient {
options: optimizely.OptimizelyDecideOption[] = [],
overrideUserId?: string,
overrideAttributes?: optimizely.UserAttributes
): OptimizelyDecision {
): OptimizelyDecision {
const user = this.getUserContextWithOverrides(overrideUserId, overrideAttributes);
if (user.id === null) {
logger.info('Not Evaluating feature "%s" because userId is not set', key);
return createFailedDecision(key, `Not Evaluating flag ${key} because userId is not set`, user);
}
const optlyUserContext: optimizely.OptimizelyUserContext | null = this._client.createUserContext(user.id, user.attributes);
}

const optlyUserContext = this.getUserContextInstance(user);
if (optlyUserContext) {
return {
... optlyUserContext.decide(key, options),
...optlyUserContext.decide(key, options),
userContext: {
id: user.id,
attributes: user.attributes
}
}
attributes: user.attributes,
},
};
}
return createFailedDecision(key, `Not Evaluating flag ${key} because user id or attributes are not valid`, user);
}
Expand All @@ -367,25 +400,28 @@ class OptimizelyReactSDKClient implements ReactSDKClient {
if (user.id === null) {
logger.info('Not Evaluating features because userId is not set');
return {};
}
const optlyUserContext: optimizely.OptimizelyUserContext | null = this._client.createUserContext(user.id, user.attributes);
}

const optlyUserContext = this.getUserContextInstance(user);
if (optlyUserContext) {
return Object.entries(optlyUserContext.decideForKeys(keys, options))
.reduce((decisions: { [key: string]: OptimizelyDecision }, [key, decision]) => {
return Object.entries(optlyUserContext.decideForKeys(keys, options)).reduce(
(decisions: { [key: string]: OptimizelyDecision }, [key, decision]) => {
decisions[key] = {
... decision,
...decision,
userContext: {
id: user.id || '',
attributes: user.attributes,
}
}
},
};
return decisions;
}, {});
},
{}
);
}
return {};
}

public decideAll(
public decideAll(
options: optimizely.OptimizelyDecideOption[] = [],
overrideUserId?: string,
overrideAttributes?: optimizely.UserAttributes
Expand All @@ -394,20 +430,23 @@ class OptimizelyReactSDKClient implements ReactSDKClient {
if (user.id === null) {
logger.info('Not Evaluating features because userId is not set');
return {};
}
const optlyUserContext: optimizely.OptimizelyUserContext | null = this._client.createUserContext(user.id, user.attributes);
}

const optlyUserContext = this.getUserContextInstance(user);
if (optlyUserContext) {
return Object.entries(optlyUserContext.decideAll(options))
.reduce((decisions: { [key: string]: OptimizelyDecision }, [key, decision]) => {
return Object.entries(optlyUserContext.decideAll(options)).reduce(
(decisions: { [key: string]: OptimizelyDecision }, [key, decision]) => {
decisions[key] = {
... decision,
...decision,
userContext: {
id: user.id || '',
attributes: user.attributes,
}
}
},
};
return decisions;
}, {});
},
{}
);
}
return {};
}
Expand Down Expand Up @@ -462,6 +501,67 @@ class OptimizelyReactSDKClient implements ReactSDKClient {
return this._client.track(eventKey, user.id, user.attributes, eventTags);
}

/**
* Sets the forced decision for specified optimizely decision context.
* @param {optimizely.OptimizelyDecisionContext} decisionContext
* @param {optimizely.OptimizelyForcedDecision} forcedDecision
* @memberof OptimizelyReactSDKClient
*/
public setForcedDecision(
decisionContext: optimizely.OptimizelyDecisionContext,
decision: optimizely.OptimizelyForcedDecision
): void {
if (!this.userContext) {
logger.info("Can't set a forced decision because the user context has not been set yet");
return;
}

const store = clientStore.getInstance();

this.userContext.setForcedDecision(decisionContext, decision);
store.setState({
userContext: this.userContext,
});
}

/**
* Returns the forced decision for specified optimizely decision context.
* @param {optimizely.OptimizelyDecisionContext} decisionContext
* @return {(optimizely.OptimizelyForcedDecision | null)}
* @memberof OptimizelyReactSDKClient
*/
public getForcedDecision(
decisionContext: optimizely.OptimizelyDecisionContext
): optimizely.OptimizelyForcedDecision | null {
if (!this.userContext) {
logger.info("Can't get a forced decision because the user context has not been set yet");
return null;
}
return this.userContext.getForcedDecision(decisionContext);
}

/**
* Removes the forced decision for specified optimizely decision context.
* @param {optimizely.OptimizelyDecisionContext} decisionContext
* @return {boolean}
* @memberof OptimizelyReactSDKClient
*/
public removeForcedDecision(decisionContext: optimizely.OptimizelyDecisionContext): boolean {
if (!this.userContext) {
logger.info("Can't remove a forced decision because the user context has not been set yet");
return false;
}

const store = clientStore.getInstance();
const decision = this.userContext.removeForcedDecision(decisionContext);

store.setState({
userContext: this.userContext,
});

return decision;
}

/**
* Returns true if the feature is enabled for the given user
* @param {string} feature
Expand Down
42 changes: 32 additions & 10 deletions src/hooks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,11 +15,12 @@
*/
import { useCallback, useContext, useEffect, useState, useRef } from 'react';

import { UserAttributes, OptimizelyDecideOption } from '@optimizely/optimizely-sdk';
import { UserAttributes, OptimizelyDecideOption, OptimizelyUserContext } from '@optimizely/optimizely-sdk';
import { getLogger, LoggerFacade } from '@optimizely/js-sdk-logging';

import { setupAutoUpdateListeners } from './autoUpdate';
import { ReactSDKClient, VariableValuesObject, OnReadyResult } from './client';
import clientStore from './store';
import { OptimizelyContext } from './Context';
import { areAttributesEqual, OptimizelyDecision, createFailedDecision } from './utils';

Expand Down Expand Up @@ -342,23 +343,28 @@ export const useFeature: UseFeature = (featureKey, options = {}, overrides = {})
* ClientReady and DidTimeout provide signals to handle this scenario.
*/
export const useDecision: UseDecision = (flagKey, options = {}, overrides = {}) => {
const [userContext, setUserContext] = useState<OptimizelyUserContext | null>(null);
const store = clientStore.getInstance();
const { optimizely, isServerSide, timeout } = useContext(OptimizelyContext);
if (!optimizely) {
throw new Error('optimizely prop must be supplied via a parent <OptimizelyProvider>');
}

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,
Expand Down Expand Up @@ -388,7 +394,7 @@ export const useDecision: UseDecision = (flagKey, options = {}, overrides = {})
// Subscribe to initialzation promise only
// 1. When client is using Sdk Key, which means the initialization will be asynchronous
// and we need to wait for the promise and update decision.
// 2. When client is using datafile only but client is not ready yet which means user
// 2. When client is using datafile only but client is not ready yet which means user
// was provided as a promise and we need to subscribe and wait for user to become available.
if (optimizely.getIsUsingSdkKey() || !isClientReady) {
subscribeToInitialization(optimizely, finalReadyTimeout, initState => {
Expand All @@ -400,6 +406,13 @@ export const useDecision: UseDecision = (flagKey, options = {}, overrides = {})
}
}, []);

useEffect(() => {
// Subscribe to the observable store to listen to changes in the optimizely client.
store.subscribe(state => {
setUserContext(state.userContext);
});
}, []);

useEffect(() => {
// Subscribe to update after first datafile is fetched and readyPromise is resolved to avoid redundant rendering.
if (optimizely.getIsReadyPromiseFulfilled() && options.autoUpdate) {
Expand All @@ -413,5 +426,14 @@ export const useDecision: UseDecision = (flagKey, options = {}, overrides = {})
return (): void => {};
}, [optimizely.getIsReadyPromiseFulfilled(), options.autoUpdate, optimizely, flagKey, getCurrentDecision]);

useEffect(() => {
if (userContext) {
setState(prevState => ({
...prevState,
...getCurrentDecision(),
}));
}
}, [userContext]);

return [state.decision, state.clientReady, state.didTimeout];
};
Loading

0 comments on commit 6a39b87

Please sign in to comment.