Skip to content

Commit b387abb

Browse files
authored
fix(hooks): Return latest decision values on first call, and re-render in response to decision input changes (#64)
Summary: This PR addresses two issues with the useExperiment and useFeature hooks: - Changes to the overrides argument do not trigger a re-render - Even after the client instance is ready, the first call returns false (for feature enabled status from useFeature) or null (for variation assignment from useExperiment). A boolean-returning isReady method is added to ReactSDKClient. When isReady returns true, useFeature and useExperiment don't wait for the fulfillment of the onReady promise before calling their respective decision methods. Arguments that affect decision results (experiment/feature key + overrides) are tracked in state, so we can compare prior and current values. When values change, the decision is recomputed. I followed the pattern described here, treating the decision state as being derived from the arguments. The effect that subscribes to updates is also changed to depend on the decision inputs. Since override user attributes are an object, I used a ref combined with a custom equality function to ensure that the dependency array receives a new object reference only when the contents of the attributes are different. Test Plan: - Manually tested using an example React app. Checked useFeature and useExperiment return values both before & after client became ready. Checked that useFeature and useExperiment trigger re-renders when entity key or override arguments change. - Unit tests: updated, and added more
1 parent 3571384 commit b387abb

File tree

9 files changed

+524
-112
lines changed

9 files changed

+524
-112
lines changed

.eslintrc.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ module.exports = {
1414
node: true,
1515
},
1616
extends: [
17+
'plugin:react-hooks/recommended',
1718
'eslint:recommended',
1819
'plugin:@typescript-eslint/recommended',
1920
'prettier/@typescript-eslint',

package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,7 @@
5353
"eslint-config-prettier": "^6.10.0",
5454
"eslint-plugin-prettier": "^3.1.2",
5555
"eslint-plugin-react": "^7.19.0",
56+
"eslint-plugin-react-hooks": "^4.1.0",
5657
"jest": "^23.6.0",
5758
"prettier": "1.19.1",
5859
"react": "^16.8.0",

src/Experiment.spec.tsx

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ describe('<OptimizelyExperiment>', () => {
5151
id: 'testuser',
5252
attributes: {},
5353
},
54+
isReady: jest.fn().mockReturnValue(false),
5455
} as unknown) as ReactSDKClient;
5556
});
5657

@@ -62,7 +63,7 @@ describe('<OptimizelyExperiment>', () => {
6263
});
6364

6465
describe('when isServerSide prop is false', () => {
65-
it('should wait until onReady() is resolved then render result of activate', async () => {
66+
it('should wait client is ready then render result of activate', async () => {
6667
const component = mount(
6768
<OptimizelyProvider optimizely={optimizelyMock} timeout={100}>
6869
<OptimizelyExperiment experiment="experiment1">{variation => variation}</OptimizelyExperiment>
@@ -72,7 +73,10 @@ describe('<OptimizelyExperiment>', () => {
7273
expect(optimizelyMock.onReady).toHaveBeenCalledWith({ timeout: 100 });
7374
// while it's waiting for onReady()
7475
expect(component.text()).toBe('');
76+
77+
// Simulate client becoming ready: onReady resolving, firing config update notification
7578
resolver.resolve({ success: true });
79+
(optimizelyMock.notificationCenter.addNotificationListener as jest.Mock).mock.calls[0][1]();
7680

7781
await optimizelyMock.onReady();
7882

@@ -94,7 +98,10 @@ describe('<OptimizelyExperiment>', () => {
9498
expect(optimizelyMock.onReady).toHaveBeenCalledWith({ timeout: 200 });
9599
// while it's waiting for onReady()
96100
expect(component.text()).toBe('');
101+
102+
// Simulate client becoming ready: onReady resolving, firing config update notification
97103
resolver.resolve({ success: true });
104+
(optimizelyMock.notificationCenter.addNotificationListener as jest.Mock).mock.calls[0][1]();
98105

99106
await optimizelyMock.onReady();
100107

@@ -113,7 +120,10 @@ describe('<OptimizelyExperiment>', () => {
113120
expect(optimizelyMock.onReady).toHaveBeenCalledWith({ timeout: 200 });
114121
// while it's waiting for onReady()
115122
expect(component.text()).toBe('');
123+
124+
// Simulate client becoming ready: onReady resolving, firing config update notification
116125
resolver.resolve({ success: true });
126+
(optimizelyMock.notificationCenter.addNotificationListener as jest.Mock).mock.calls[0][1]();
117127

118128
await optimizelyMock.onReady();
119129

@@ -133,7 +143,10 @@ describe('<OptimizelyExperiment>', () => {
133143

134144
// while it's waiting for onReady()
135145
expect(component.text()).toBe('');
146+
147+
// Simulate client becoming ready: onReady resolving, firing config update notification
136148
resolver.resolve({ success: true });
149+
(optimizelyMock.notificationCenter.addNotificationListener as jest.Mock).mock.calls[0][1]();
137150

138151
await optimizelyMock.onReady();
139152

@@ -198,7 +211,10 @@ describe('<OptimizelyExperiment>', () => {
198211
expect(optimizelyMock.onReady).toHaveBeenCalledWith({ timeout: 100 });
199212
// while it's waiting for onReady()
200213
expect(component.text()).toBe('');
214+
215+
// Simulate client becoming ready: onReady resolving, firing config update notification
201216
resolver.resolve({ success: true });
217+
(optimizelyMock.notificationCenter.addNotificationListener as jest.Mock).mock.calls[0][1]();
202218

203219
await optimizelyMock.onReady();
204220

@@ -220,7 +236,10 @@ describe('<OptimizelyExperiment>', () => {
220236

221237
// while it's waiting for onReady()
222238
expect(component.text()).toBe('');
239+
240+
// Simulate client becoming ready: onReady resolving, firing config update notification
223241
resolver.resolve({ success: true });
242+
(optimizelyMock.notificationCenter.addNotificationListener as jest.Mock).mock.calls[0][1]();
224243

225244
await optimizelyMock.onReady();
226245

@@ -265,7 +284,10 @@ describe('<OptimizelyExperiment>', () => {
265284
expect(optimizelyMock.onReady).toHaveBeenCalledWith({ timeout: 100 });
266285
// while it's waiting for onReady()
267286
expect(component.text()).toBe('');
287+
288+
// Simulate client becoming ready: onReady resolving, firing config update notification
268289
resolver.resolve({ success: true });
290+
(optimizelyMock.notificationCenter.addNotificationListener as jest.Mock).mock.calls[0][1]();
269291

270292
await optimizelyMock.onReady();
271293

@@ -304,6 +326,9 @@ describe('<OptimizelyExperiment>', () => {
304326
expect(component.text()).toBe('');
305327
resolver.resolve({ success: true });
306328

329+
// Simulate config update update notification firing after datafile becomes available.
330+
(optimizelyMock.notificationCenter.addNotificationListener as jest.Mock).mock.calls[0][1]();
331+
307332
await optimizelyMock.onReady();
308333

309334
component.update();

src/Feature.spec.tsx

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ describe('<OptimizelyFeature>', () => {
5252
id: 'testuser',
5353
attributes: {},
5454
},
55+
isReady: jest.fn().mockReturnValue(false),
5556
} as unknown) as ReactSDKClient;
5657
});
5758
it('throws an error when not rendered in the context of an OptimizelyProvider', () => {
@@ -75,7 +76,10 @@ describe('<OptimizelyFeature>', () => {
7576

7677
// while it's waiting for onReady()
7778
expect(component.text()).toBe('');
79+
80+
// Simulate client becoming ready
7881
resolver.resolve({ success: true });
82+
(optimizelyMock.notificationCenter.addNotificationListener as jest.Mock).mock.calls[0][1]();
7983

8084
await optimizelyMock.onReady();
8185

@@ -99,7 +103,10 @@ describe('<OptimizelyFeature>', () => {
99103

100104
// while it's waiting for onReady()
101105
expect(component.text()).toBe('');
106+
107+
// Simulate client becoming ready
102108
resolver.resolve({ success: true });
109+
(optimizelyMock.notificationCenter.addNotificationListener as jest.Mock).mock.calls[0][1]();
103110

104111
await optimizelyMock.onReady();
105112

@@ -123,7 +130,10 @@ describe('<OptimizelyFeature>', () => {
123130

124131
// while it's waiting for onReady()
125132
expect(component.text()).toBe('');
133+
134+
// Simulate client becoming ready
126135
resolver.resolve({ success: true });
136+
(optimizelyMock.notificationCenter.addNotificationListener as jest.Mock).mock.calls[0][1]();
127137

128138
await optimizelyMock.onReady();
129139

@@ -147,7 +157,10 @@ describe('<OptimizelyFeature>', () => {
147157

148158
// while it's waiting for onReady()
149159
expect(component.text()).toBe('');
160+
161+
// Simulate client becoming ready
150162
resolver.resolve({ success: true });
163+
(optimizelyMock.notificationCenter.addNotificationListener as jest.Mock).mock.calls[0][1]();
151164

152165
await optimizelyMock.onReady();
153166

@@ -169,7 +182,10 @@ describe('<OptimizelyFeature>', () => {
169182

170183
// while it's waiting for onReady()
171184
expect(component.text()).toBe('');
185+
186+
// Simulate client becoming ready
172187
resolver.resolve({ success: true });
188+
(optimizelyMock.notificationCenter.addNotificationListener as jest.Mock).mock.calls[0][1]();
173189

174190
await optimizelyMock.onReady();
175191

@@ -194,7 +210,11 @@ describe('<OptimizelyFeature>', () => {
194210

195211
// while it's waiting for onReady()
196212
expect(component.text()).toBe('');
213+
214+
// Simulate client becoming ready
215+
const updateFn = (optimizelyMock.notificationCenter.addNotificationListener as jest.Mock).mock.calls[0][1];
197216
resolver.resolve({ success: true });
217+
updateFn();
198218

199219
await optimizelyMock.onReady();
200220

@@ -204,7 +224,6 @@ describe('<OptimizelyFeature>', () => {
204224
expect(optimizelyMock.getFeatureVariables).toHaveBeenCalledWith('feature1', undefined, undefined);
205225
expect(component.text()).toBe('true|bar');
206226

207-
const updateFn = (optimizelyMock.notificationCenter.addNotificationListener as jest.Mock).mock.calls[0][1];
208227
// change the return value of activate
209228
const mockIFE = optimizelyMock.isFeatureEnabled as jest.Mock;
210229
mockIFE.mockImplementationOnce(() => false);
@@ -236,6 +255,9 @@ describe('<OptimizelyFeature>', () => {
236255

237256
// while it's waiting for onReady()
238257
expect(component.text()).toBe('');
258+
259+
// Simulate client becoming ready
260+
(optimizelyMock.notificationCenter.addNotificationListener as jest.Mock).mock.calls[0][1]();
239261
resolver.resolve({ success: true });
240262

241263
await optimizelyMock.onReady();
@@ -281,6 +303,8 @@ describe('<OptimizelyFeature>', () => {
281303
expect(component.text()).toBe('');
282304
resolver.resolve({ success: false, reason: 'fail', dataReadyPromise: Promise.resolve() });
283305

306+
// Simulate config update notification firing after datafile fetched
307+
(optimizelyMock.notificationCenter.addNotificationListener as jest.Mock).mock.calls[0][1]();
284308
await optimizelyMock.onReady().then(res => res.dataReadyPromise);
285309

286310
component.update();

src/client.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,7 @@ export interface ReactSDKClient extends optimizely.Client {
4343
onReady(opts?: { timeout?: number }): Promise<any>;
4444
setUser(userInfo: { id: string; attributes?: { [key: string]: any } }): void;
4545
onUserUpdate(handler: OnUserUpdateHandler): DisposeFn;
46+
isReady(): boolean;
4647

4748
activate(
4849
experimentKey: string,
@@ -155,6 +156,8 @@ class OptimizelyReactSDKClient implements ReactSDKClient {
155156
// promise keeping track of async requests for initializing client instance
156157
private dataReadyPromise: Promise<OnReadyResult>;
157158

159+
private dataReadyPromiseFulfilled = false;
160+
158161
/**
159162
* Creates an instance of OptimizelyReactSDKClient.
160163
* @param {optimizely.Config} [config={}]
@@ -176,6 +179,7 @@ class OptimizelyReactSDKClient implements ReactSDKClient {
176179
}).then(() => ({ success: true }));
177180

178181
this.dataReadyPromise = Promise.all([this.userPromise, this._client.onReady()]).then(() => {
182+
this.dataReadyPromiseFulfilled = true;
179183
return {
180184
success: true,
181185
reason: 'datafile and user resolved',
@@ -233,6 +237,10 @@ class OptimizelyReactSDKClient implements ReactSDKClient {
233237
};
234238
}
235239

240+
isReady(): boolean {
241+
return this.dataReadyPromiseFulfilled;
242+
}
243+
236244
/**
237245
* Buckets visitor and sends impression event to Optimizely
238246
* @param {string} experimentKey

0 commit comments

Comments
 (0)