Skip to content

Commit 9ee3cf3

Browse files
author
James Fox
authored
feat (Hooks): Add useExperiment hook and refactor <OptimizelyExperiment> to use it (#36)
## Summary Adds a `useExperiment` hook and updates `<OptimizelyExperiment>` to use it. ## Test Plan Unit tests added for the new hook, refactored for the `<OptimizelyExperiment>` usage, and new tests added for the passing of overrides. Addresses #30
1 parent e2cf17c commit 9ee3cf3

File tree

7 files changed

+511
-330
lines changed

7 files changed

+511
-330
lines changed

CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
99

1010
- Refactored `<OptimizelyFeature>` to a functional component that uses the `useFeature` hook under the hood. See [#32](https://github.com/optimizely/react-sdk/pull/32) for more details.
1111

12+
- Refactored `<OptimizelyExperiment>` to a functional component that uses the `useExperiment` hook under the hood. See [#36](https://github.com/optimizely/react-sdk/pull/36) for more details.
13+
14+
- Added `useExperiment` hook
15+
- Can be used to retrieve the variation for an experiment. See [#36](https://github.com/optimizely/react-sdk/pull/36) for more details.
16+
1217
### New Features
1318

1419
- Added `useFeature` hook

README.md

Lines changed: 218 additions & 166 deletions
Large diffs are not rendered by default.

src/Experiment.spec.tsx

Lines changed: 58 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -72,13 +72,13 @@ describe('<OptimizelyExperiment>', () => {
7272
expect(optimizelyMock.onReady).toHaveBeenCalledWith({ timeout: 100 });
7373
// while it's waiting for onReady()
7474
expect(component.text()).toBe('');
75-
resolver.resolve({ sucess: true });
75+
resolver.resolve({ success: true });
7676

7777
await optimizelyMock.onReady();
7878

7979
component.update();
8080

81-
expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1');
81+
expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1', undefined, undefined);
8282
expect(component.text()).toBe(variationKey);
8383
});
8484

@@ -94,11 +94,11 @@ describe('<OptimizelyExperiment>', () => {
9494
expect(optimizelyMock.onReady).toHaveBeenCalledWith({ timeout: 200 });
9595
// while it's waiting for onReady()
9696
expect(component.text()).toBe('');
97-
resolver.resolve({ sucess: true });
97+
resolver.resolve({ success: true });
9898

9999
await optimizelyMock.onReady();
100100

101-
expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1');
101+
expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1', undefined, undefined);
102102
});
103103

104104
it(`should use the Experiment prop's timeout when there is no timeout passed to <Provider>`, async () => {
@@ -117,7 +117,7 @@ describe('<OptimizelyExperiment>', () => {
117117

118118
await optimizelyMock.onReady();
119119

120-
expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1');
120+
expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1', undefined, undefined);
121121
});
122122

123123
it('should render using <OptimizelyVariation> when the variationKey matches', async () => {
@@ -182,7 +182,55 @@ describe('<OptimizelyExperiment>', () => {
182182
expect(component.text()).toBe('');
183183
});
184184

185-
describe('when the onReady() promise return { sucess: false }', () => {
185+
it('should pass the override props through', async () => {
186+
const component = mount(
187+
<OptimizelyProvider optimizely={optimizelyMock} timeout={100}>
188+
<OptimizelyExperiment
189+
experiment="experiment1"
190+
overrideUserId="james123"
191+
overrideAttributes={{ betaUser: true }}
192+
>
193+
{variation => variation}
194+
</OptimizelyExperiment>
195+
</OptimizelyProvider>
196+
);
197+
198+
expect(optimizelyMock.onReady).toHaveBeenCalledWith({ timeout: 100 });
199+
// while it's waiting for onReady()
200+
expect(component.text()).toBe('');
201+
resolver.resolve({ success: true });
202+
203+
await optimizelyMock.onReady();
204+
205+
component.update();
206+
207+
expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1', 'james123', { betaUser: true });
208+
209+
expect(component.text()).toBe('variationResult');
210+
});
211+
212+
it('should pass the values for clientReady and didTimeout', async () => {
213+
const component = mount(
214+
<OptimizelyProvider optimizely={optimizelyMock} timeout={100}>
215+
<OptimizelyExperiment experiment="experiment1">
216+
{(variation, clientReady, didTimeout) => `${variation}|${clientReady}|${didTimeout}`}
217+
</OptimizelyExperiment>
218+
</OptimizelyProvider>
219+
);
220+
221+
// while it's waiting for onReady()
222+
expect(component.text()).toBe('');
223+
resolver.resolve({ success: true });
224+
225+
await optimizelyMock.onReady();
226+
227+
component.update();
228+
229+
expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1', undefined, undefined);
230+
expect(component.text()).toBe('variationResult|true|false');
231+
});
232+
233+
describe('when the onReady() promise return { success: false }', () => {
186234
it('should still render', async () => {
187235
const component = mount(
188236
<OptimizelyProvider optimizely={optimizelyMock}>
@@ -223,7 +271,7 @@ describe('<OptimizelyExperiment>', () => {
223271

224272
component.update();
225273

226-
expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1');
274+
expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1', undefined, undefined);
227275

228276
expect(component.text()).toBe('variationResult');
229277

@@ -238,7 +286,7 @@ describe('<OptimizelyExperiment>', () => {
238286

239287
component.update();
240288

241-
expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1');
289+
expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1', undefined, undefined);
242290
expect(component.text()).toBe('newVariation');
243291
});
244292

@@ -260,7 +308,7 @@ describe('<OptimizelyExperiment>', () => {
260308

261309
component.update();
262310

263-
expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1');
311+
expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1', undefined, undefined);
264312

265313
expect(component.text()).toBe('variationResult');
266314

@@ -274,7 +322,7 @@ describe('<OptimizelyExperiment>', () => {
274322

275323
expect(optimizelyMock.activate).toBeCalledTimes(2);
276324

277-
expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1');
325+
expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1', undefined, undefined);
278326
expect(component.text()).toBe('newVariation');
279327
});
280328
});

src/Experiment.tsx

Lines changed: 41 additions & 140 deletions
Original file line numberDiff line numberDiff line change
@@ -14,167 +14,68 @@
1414
* limitations under the License.
1515
*/
1616
import * as React from 'react';
17-
import { withOptimizely, WithOptimizelyProps } from './withOptimizely';
18-
import { VariationProps } from './Variation';
19-
import { VariableValuesObject, OnReadyResult, DEFAULT_ON_READY_TIMEOUT } from './client';
20-
import * as logging from '@optimizely/js-sdk-logging';
2117

22-
const logger = logging.getLogger('<OptimizelyExperiment>');
18+
import { UserAttributes } from '@optimizely/optimizely-sdk';
2319

24-
export type ChildrenRenderFunction = (variableValues: VariableValuesObject) => React.ReactNode;
20+
import { useExperiment } from './hooks';
21+
import { VariationProps } from './Variation';
22+
import { withOptimizely, WithOptimizelyProps } from './withOptimizely';
2523

26-
type ChildRenderFunction = (variation: string | null) => React.ReactNode;
24+
export type ChildrenRenderFunction = (
25+
variation: string | null,
26+
clientReady?: boolean,
27+
didTimeout?: boolean
28+
) => React.ReactNode;
2729

2830
export interface ExperimentProps extends WithOptimizelyProps {
2931
// TODO add support for overrideUserId
3032
experiment: string;
3133
autoUpdate?: boolean;
3234
timeout?: number;
33-
children: React.ReactNode | ChildRenderFunction;
34-
}
35-
36-
export interface ExperimentState {
37-
canRender: boolean;
38-
variation: string | null;
35+
overrideUserId?: string;
36+
overrideAttributes?: UserAttributes;
37+
children: React.ReactNode | ChildrenRenderFunction;
3938
}
4039

41-
export class Experiment extends React.Component<ExperimentProps, ExperimentState> {
42-
private optimizelyNotificationId?: number;
43-
private unregisterUserListener: () => void = () => {};
44-
private autoUpdate = false;
45-
46-
constructor(props: ExperimentProps) {
47-
super(props);
48-
49-
const { autoUpdate, isServerSide, optimizely, experiment } = props;
50-
this.autoUpdate = !!autoUpdate;
51-
52-
if (isServerSide) {
53-
if (!optimizely) {
54-
throw new Error('optimizely prop must be supplied');
55-
}
56-
const variation = optimizely.activate(experiment);
57-
this.state = {
58-
canRender: true,
59-
variation,
60-
};
61-
} else {
62-
this.state = {
63-
canRender: false,
64-
variation: null,
65-
};
66-
}
40+
const Experiment: React.FunctionComponent<ExperimentProps> = props => {
41+
const { experiment, autoUpdate, timeout, overrideUserId, overrideAttributes, children } = props;
42+
const [variation, clientReady, didTimeout] = useExperiment(
43+
experiment,
44+
{ timeout, autoUpdate },
45+
{ overrideUserId, overrideAttributes }
46+
);
47+
48+
if (!clientReady && !didTimeout) {
49+
// Only block rendering while were waiting for the client within the allowed timeout.
50+
return null;
6751
}
6852

69-
componentDidMount() {
70-
const { experiment, optimizely, optimizelyReadyTimeout, isServerSide, timeout } = this.props;
71-
if (!optimizely) {
72-
throw new Error('optimizely prop must be supplied');
73-
}
74-
if (isServerSide) {
75-
return;
76-
}
77-
78-
// allow overriding of the ready timeout via the `timeout` prop passed to <Experiment />
79-
const finalReadyTimeout: number | undefined = timeout !== undefined ? timeout : optimizelyReadyTimeout;
80-
81-
optimizely.onReady({ timeout: finalReadyTimeout }).then((res: OnReadyResult) => {
82-
if (res.success) {
83-
logger.info('experiment="%s" successfully rendered for user="%s"', experiment, optimizely.user.id);
84-
} else {
85-
logger.info(
86-
'experiment="%s" could not be checked before timeout of %sms, reason="%s" ',
87-
experiment,
88-
timeout === undefined ? DEFAULT_ON_READY_TIMEOUT : timeout,
89-
res.reason || ''
90-
);
91-
}
92-
93-
const variation = optimizely.activate(experiment);
94-
this.setState({
95-
canRender: true,
96-
variation,
97-
});
98-
if (this.autoUpdate) {
99-
this.setupAutoUpdateListeners();
100-
}
101-
});
53+
if (children != null && typeof children === 'function') {
54+
// Wrap the return value here in a Fragment to please the HOC's expected React.ComponentType
55+
// See https://github.com/DefinitelyTyped/DefinitelyTyped/issues/18051
56+
return <>{(children as ChildrenRenderFunction)(variation, clientReady, didTimeout)}</>;
10257
}
10358

104-
setupAutoUpdateListeners() {
105-
const { optimizely, experiment } = this.props;
106-
if (optimizely === null) {
107-
return;
108-
}
109-
110-
this.optimizelyNotificationId = optimizely.notificationCenter.addNotificationListener(
111-
'OPTIMIZELY_CONFIG_UPDATE',
112-
() => {
113-
logger.info(
114-
'OPTIMIZELY_CONFIG_UPDATE, re-evaluating experiment="%s" for user="%s"',
115-
experiment,
116-
optimizely.user.id
117-
);
118-
const variation = optimizely.activate(experiment);
119-
this.setState({
120-
variation,
121-
});
122-
}
123-
);
124-
125-
this.unregisterUserListener = optimizely.onUserUpdate(() => {
126-
logger.info('User update, re-evaluating experiment="%s" for user="%s"', experiment, optimizely.user.id);
127-
const variation = optimizely.activate(experiment);
128-
this.setState({
129-
variation,
130-
});
131-
});
132-
}
59+
let match: React.ReactElement<VariationProps> | null = null;
13360

134-
componentWillUnmount() {
135-
const { optimizely, isServerSide } = this.props;
136-
if (isServerSide || !this.autoUpdate) {
61+
// We use React.Children.forEach instead of React.Children.toArray().find()
62+
// here because toArray adds keys to all child elements and we do not want
63+
// to trigger an unmount/remount
64+
React.Children.forEach(children, (child: React.ReactElement<VariationProps>) => {
65+
if (match || !React.isValidElement(child)) {
13766
return;
13867
}
139-
if (optimizely && this.optimizelyNotificationId) {
140-
optimizely.notificationCenter.removeNotificationListener(this.optimizelyNotificationId);
141-
}
142-
this.unregisterUserListener();
143-
}
14468

145-
render() {
146-
const { children } = this.props;
147-
const { variation, canRender } = this.state;
148-
149-
if (!canRender) {
150-
return null;
151-
}
152-
153-
if (children != null && typeof children === 'function') {
154-
return (children as ChildRenderFunction)(variation);
155-
}
156-
157-
let match: React.ReactElement<VariationProps> | null = null;
158-
159-
// We use React.Children.forEach instead of React.Children.toArray().find()
160-
// here because toArray adds keys to all child elements and we do not want
161-
// to trigger an unmount/remount
162-
React.Children.forEach(this.props.children, (child: React.ReactElement<VariationProps>) => {
163-
if (match || !React.isValidElement(child)) {
164-
return;
165-
}
166-
167-
if (child.props.variation) {
168-
if (variation === child.props.variation) {
169-
match = child;
170-
}
171-
} else if (child.props.default) {
69+
if (child.props.variation) {
70+
if (variation === child.props.variation) {
17271
match = child;
17372
}
174-
});
73+
} else if (child.props.default) {
74+
match = child;
75+
}
76+
});
17577

176-
return match ? React.cloneElement(match, { variation: variation }) : null;
177-
}
178-
}
78+
return match ? React.cloneElement(match, { variation }) : null;
79+
};
17980

18081
export const OptimizelyExperiment = withOptimizely(Experiment);

src/Feature.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -20,18 +20,20 @@ import { VariableValuesObject } from './client';
2020
import { useFeature } from './hooks';
2121
import { withOptimizely, WithOptimizelyProps } from './withOptimizely';
2222

23+
export type ChildrenRenderFunction = (
24+
isEnabled: boolean,
25+
variables: VariableValuesObject,
26+
clientReady: boolean,
27+
didTimeout: boolean
28+
) => React.ReactNode;
29+
2330
export interface FeatureProps extends WithOptimizelyProps {
2431
feature: string;
2532
timeout?: number;
2633
autoUpdate?: boolean;
2734
overrideUserId?: string;
2835
overrideAttributes?: UserAttributes;
29-
children: (
30-
isEnabled: boolean,
31-
variables: VariableValuesObject,
32-
clientReady: boolean,
33-
didTimeout: boolean
34-
) => React.ReactNode;
36+
children: ChildrenRenderFunction;
3537
}
3638

3739
const FeatureComponent: React.FunctionComponent<FeatureProps> = props => {

0 commit comments

Comments
 (0)