Skip to content

feat (Hooks): Add useExperiment hook and refactor <OptimizelyExperiment> to use it #36

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Mar 18, 2020
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

- 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.

- 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.

- Added `useExperiment` hook
- Can be used to retrieve the variation for an experiment. See [#36](https://github.com/optimizely/react-sdk/pull/36) for more details.

### New Features

- Added `useFeature` hook
Expand Down
384 changes: 218 additions & 166 deletions README.md

Large diffs are not rendered by default.

68 changes: 58 additions & 10 deletions src/Experiment.spec.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -72,13 +72,13 @@ describe('<OptimizelyExperiment>', () => {
expect(optimizelyMock.onReady).toHaveBeenCalledWith({ timeout: 100 });
// while it's waiting for onReady()
expect(component.text()).toBe('');
resolver.resolve({ sucess: true });
resolver.resolve({ success: true });

await optimizelyMock.onReady();

component.update();

expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1');
expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1', undefined, undefined);
expect(component.text()).toBe(variationKey);
});

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

await optimizelyMock.onReady();

expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1');
expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1', undefined, undefined);
});

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

await optimizelyMock.onReady();

expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1');
expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1', undefined, undefined);
});

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

describe('when the onReady() promise return { sucess: false }', () => {
it('should pass the override props through', async () => {
const component = mount(
<OptimizelyProvider optimizely={optimizelyMock} timeout={100}>
<OptimizelyExperiment
experiment="experiment1"
overrideUserId="james123"
overrideAttributes={{ betaUser: true }}
>
{variation => variation}
</OptimizelyExperiment>
</OptimizelyProvider>
);

expect(optimizelyMock.onReady).toHaveBeenCalledWith({ timeout: 100 });
// while it's waiting for onReady()
expect(component.text()).toBe('');
resolver.resolve({ success: true });

await optimizelyMock.onReady();

component.update();

expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1', 'james123', { betaUser: true });

expect(component.text()).toBe('variationResult');
});

it('should pass the values for clientReady and didTimeout', async () => {
const component = mount(
<OptimizelyProvider optimizely={optimizelyMock} timeout={100}>
<OptimizelyExperiment experiment="experiment1">
{(variation, clientReady, didTimeout) => `${variation}|${clientReady}|${didTimeout}`}
</OptimizelyExperiment>
</OptimizelyProvider>
);

// while it's waiting for onReady()
expect(component.text()).toBe('');
resolver.resolve({ success: true });

await optimizelyMock.onReady();

component.update();

expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1', undefined, undefined);
expect(component.text()).toBe('variationResult|true|false');
});

describe('when the onReady() promise return { success: false }', () => {
it('should still render', async () => {
const component = mount(
<OptimizelyProvider optimizely={optimizelyMock}>
Expand Down Expand Up @@ -223,7 +271,7 @@ describe('<OptimizelyExperiment>', () => {

component.update();

expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1');
expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1', undefined, undefined);

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

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

component.update();

expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1');
expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1', undefined, undefined);
expect(component.text()).toBe('newVariation');
});

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

component.update();

expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1');
expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1', undefined, undefined);

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

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

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

expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1');
expect(optimizelyMock.activate).toHaveBeenCalledWith('experiment1', undefined, undefined);
expect(component.text()).toBe('newVariation');
});
});
Expand Down
181 changes: 41 additions & 140 deletions src/Experiment.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -14,167 +14,68 @@
* limitations under the License.
*/
import * as React from 'react';
import { withOptimizely, WithOptimizelyProps } from './withOptimizely';
import { VariationProps } from './Variation';
import { VariableValuesObject, OnReadyResult, DEFAULT_ON_READY_TIMEOUT } from './client';
import * as logging from '@optimizely/js-sdk-logging';

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

export type ChildrenRenderFunction = (variableValues: VariableValuesObject) => React.ReactNode;
import { useExperiment } from './hooks';
import { VariationProps } from './Variation';
import { withOptimizely, WithOptimizelyProps } from './withOptimizely';

type ChildRenderFunction = (variation: string | null) => React.ReactNode;
export type ChildrenRenderFunction = (
variation: string | null,
clientReady?: boolean,
didTimeout?: boolean
) => React.ReactNode;

export interface ExperimentProps extends WithOptimizelyProps {
// TODO add support for overrideUserId
experiment: string;
autoUpdate?: boolean;
timeout?: number;
children: React.ReactNode | ChildRenderFunction;
}

export interface ExperimentState {
canRender: boolean;
variation: string | null;
overrideUserId?: string;
overrideAttributes?: UserAttributes;
children: React.ReactNode | ChildrenRenderFunction;
}

export class Experiment extends React.Component<ExperimentProps, ExperimentState> {
private optimizelyNotificationId?: number;
private unregisterUserListener: () => void = () => {};
private autoUpdate = false;

constructor(props: ExperimentProps) {
super(props);

const { autoUpdate, isServerSide, optimizely, experiment } = props;
this.autoUpdate = !!autoUpdate;

if (isServerSide) {
if (!optimizely) {
throw new Error('optimizely prop must be supplied');
}
const variation = optimizely.activate(experiment);
this.state = {
canRender: true,
variation,
};
} else {
this.state = {
canRender: false,
variation: null,
};
}
const Experiment: React.FunctionComponent<ExperimentProps> = props => {
const { experiment, autoUpdate, timeout, overrideUserId, overrideAttributes, children } = props;
const [variation, clientReady, didTimeout] = useExperiment(
experiment,
{ timeout, autoUpdate },
{ overrideUserId, overrideAttributes }
);

if (!clientReady && !didTimeout) {
// Only block rendering while were waiting for the client within the allowed timeout.
return null;
}

componentDidMount() {
const { experiment, optimizely, optimizelyReadyTimeout, isServerSide, timeout } = this.props;
if (!optimizely) {
throw new Error('optimizely prop must be supplied');
}
if (isServerSide) {
return;
}

// allow overriding of the ready timeout via the `timeout` prop passed to <Experiment />
const finalReadyTimeout: number | undefined = timeout !== undefined ? timeout : optimizelyReadyTimeout;

optimizely.onReady({ timeout: finalReadyTimeout }).then((res: OnReadyResult) => {
if (res.success) {
logger.info('experiment="%s" successfully rendered for user="%s"', experiment, optimizely.user.id);
} else {
logger.info(
'experiment="%s" could not be checked before timeout of %sms, reason="%s" ',
experiment,
timeout === undefined ? DEFAULT_ON_READY_TIMEOUT : timeout,
res.reason || ''
);
}

const variation = optimizely.activate(experiment);
this.setState({
canRender: true,
variation,
});
if (this.autoUpdate) {
this.setupAutoUpdateListeners();
}
});
if (children != null && typeof children === 'function') {
// Wrap the return value here in a Fragment to please the HOC's expected React.ComponentType
// See https://github.com/DefinitelyTyped/DefinitelyTyped/issues/18051
return <>{(children as ChildrenRenderFunction)(variation, clientReady, didTimeout)}</>;
}

setupAutoUpdateListeners() {
const { optimizely, experiment } = this.props;
if (optimizely === null) {
return;
}

this.optimizelyNotificationId = optimizely.notificationCenter.addNotificationListener(
'OPTIMIZELY_CONFIG_UPDATE',
() => {
logger.info(
'OPTIMIZELY_CONFIG_UPDATE, re-evaluating experiment="%s" for user="%s"',
experiment,
optimizely.user.id
);
const variation = optimizely.activate(experiment);
this.setState({
variation,
});
}
);

this.unregisterUserListener = optimizely.onUserUpdate(() => {
logger.info('User update, re-evaluating experiment="%s" for user="%s"', experiment, optimizely.user.id);
const variation = optimizely.activate(experiment);
this.setState({
variation,
});
});
}
let match: React.ReactElement<VariationProps> | null = null;
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Github is being dramatic here. None of this was changed from the render method of the class component.


componentWillUnmount() {
const { optimizely, isServerSide } = this.props;
if (isServerSide || !this.autoUpdate) {
// We use React.Children.forEach instead of React.Children.toArray().find()
// here because toArray adds keys to all child elements and we do not want
// to trigger an unmount/remount
React.Children.forEach(children, (child: React.ReactElement<VariationProps>) => {
if (match || !React.isValidElement(child)) {
return;
}
if (optimizely && this.optimizelyNotificationId) {
optimizely.notificationCenter.removeNotificationListener(this.optimizelyNotificationId);
}
this.unregisterUserListener();
}

render() {
const { children } = this.props;
const { variation, canRender } = this.state;

if (!canRender) {
return null;
}

if (children != null && typeof children === 'function') {
return (children as ChildRenderFunction)(variation);
}

let match: React.ReactElement<VariationProps> | null = null;

// We use React.Children.forEach instead of React.Children.toArray().find()
// here because toArray adds keys to all child elements and we do not want
// to trigger an unmount/remount
React.Children.forEach(this.props.children, (child: React.ReactElement<VariationProps>) => {
if (match || !React.isValidElement(child)) {
return;
}

if (child.props.variation) {
if (variation === child.props.variation) {
match = child;
}
} else if (child.props.default) {
if (child.props.variation) {
if (variation === child.props.variation) {
match = child;
}
});
} else if (child.props.default) {
match = child;
}
});

return match ? React.cloneElement(match, { variation: variation }) : null;
}
}
return match ? React.cloneElement(match, { variation }) : null;
};

export const OptimizelyExperiment = withOptimizely(Experiment);
14 changes: 8 additions & 6 deletions src/Feature.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,18 +20,20 @@ import { VariableValuesObject } from './client';
import { useFeature } from './hooks';
import { withOptimizely, WithOptimizelyProps } from './withOptimizely';

export type ChildrenRenderFunction = (
isEnabled: boolean,
variables: VariableValuesObject,
clientReady: boolean,
didTimeout: boolean
) => React.ReactNode;

export interface FeatureProps extends WithOptimizelyProps {
feature: string;
timeout?: number;
autoUpdate?: boolean;
overrideUserId?: string;
overrideAttributes?: UserAttributes;
children: (
isEnabled: boolean,
variables: VariableValuesObject,
clientReady: boolean,
didTimeout: boolean
) => React.ReactNode;
children: ChildrenRenderFunction;
}

const FeatureComponent: React.FunctionComponent<FeatureProps> = props => {
Expand Down
Loading