├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── examples └── simple │ ├── README.md │ ├── package.json │ ├── public │ └── index.html │ ├── src │ ├── App.js │ ├── index.css │ └── index.js │ └── yarn.lock ├── package.json ├── rollup.config.js ├── src ├── Experiment.jsx ├── Variant.jsx ├── index.js ├── tests │ └── Experiment.test.jsx └── utils │ ├── getUserIdentifier.js │ ├── getWeight.js │ └── weightedRandom.js └── yarn.lock /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | [ 4 | "@babel/preset-env", 5 | { 6 | "targets": { 7 | "esmodules": false 8 | } 9 | } 10 | ], 11 | "@babel/preset-react" 12 | ], 13 | "plugins": [ 14 | [ 15 | "@babel/plugin-transform-runtime", 16 | { 17 | "regenerator": true 18 | } 19 | ], 20 | "@babel/plugin-syntax-dynamic-import", 21 | "@babel/plugin-syntax-import-meta", 22 | [ 23 | "@babel/plugin-proposal-decorators", 24 | { 25 | "legacy": true 26 | } 27 | ], 28 | "@babel/plugin-proposal-class-properties", 29 | "@babel/plugin-proposal-json-strings", 30 | "@babel/plugin-proposal-function-sent", 31 | "@babel/plugin-proposal-export-namespace-from", 32 | "@babel/plugin-proposal-numeric-separator", 33 | "@babel/plugin-proposal-throw-expressions", 34 | "@babel/plugin-proposal-export-default-from", 35 | "@babel/plugin-proposal-logical-assignment-operators", 36 | "@babel/plugin-proposal-optional-chaining", 37 | [ 38 | "@babel/plugin-proposal-pipeline-operator", 39 | { 40 | "proposal": "minimal" 41 | } 42 | ], 43 | "@babel/plugin-proposal-nullish-coalescing-operator", 44 | "@babel/plugin-proposal-do-expressions", 45 | "@babel/plugin-proposal-function-bind" 46 | ] 47 | } 48 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | charset = utf-8 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | insert_final_newline = true 9 | trim_trailing_whitespace = true 10 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "parser": "babel-eslint", 3 | "extends": [ 4 | "eslint-config-standard", 5 | "plugin:react/recommended" 6 | ], 7 | "env": { 8 | "es6": true 9 | }, 10 | "plugins": [ 11 | "react" 12 | ], 13 | "parserOptions": { 14 | "sourceType": "module" 15 | }, 16 | "rules": { 17 | "space-before-function-paren": 0, 18 | "react/jsx-boolean-value": 0, 19 | "no-multiple-empty-lines": [ 20 | "error", 21 | { 22 | "max": 2 23 | } 24 | ], 25 | "comma-dangle": [ 26 | "error", 27 | "only-multiline" 28 | ], 29 | "jsx-quotes": [ 30 | "error", 31 | "prefer-double" 32 | ], 33 | "react/no-unescaped-entities": [ 34 | "error", 35 | { 36 | "forbid": [ 37 | ">", 38 | "}" 39 | ] 40 | } 41 | ] 42 | }, 43 | "settings": { 44 | "react": { 45 | "version": "16.0" 46 | } 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # dependencies 2 | node_modules 3 | 4 | # builds 5 | build 6 | dist 7 | 8 | # misc 9 | .DS_Store 10 | .env 11 | .env.local 12 | .env.development.local 13 | .env.test.local 14 | .env.production.local 15 | 16 | npm-debug.log* 17 | yarn-debug.log* 18 | yarn-error.log* 19 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - 9 4 | - 8 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Michael Sulyak 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # react-split-testing 2 | 3 | [![NPM](https://img.shields.io/npm/v/react-split-testing.svg?style=flat-square)](https://www.npmjs.com/package/react-split-testing) [![Scrutinizer Code Quality](https://img.shields.io/scrutinizer/g/expert-m/react-split-testing.svg?style=flat-square)](https://scrutinizer-ci.com/g/expert-m/react-split-testing/?branch=master) [![Build Status](https://img.shields.io/scrutinizer/build/g/expert-m/react-split-testing.svg?style=flat-square)](https://scrutinizer-ci.com/g/expert-m/react-split-testing/build-status/master) [![GitHub Issues](https://img.shields.io/github/issues/expert-m/react-split-testing.svg?style=flat-square)](https://github.com/expert-m/react-split-testing/issues) [![Gitter](https://img.shields.io/badge/gitter-join_chat-blue.svg?style=flat-square)](https://gitter.im/expert_m/react-split-testing) [![License](https://img.shields.io/badge/license-MIT-blue.svg?style=flat-square)](https://opensource.org/licenses/MIT) 4 | 5 | > A/B testing, also called split testing, is an experiment where two (or more) variants of a webpage are shown to users at random, and is used as a means of determining which variant leads to a better performance. Once a variant wins, it is then shown to all users. 6 | 7 | Wrap components in [``](#variant-) and nest in [``](#experiment-). A variant is chosen randomly and saved to local storage. 8 | 9 | ```js 10 | 11 | 12 |
Version A
13 |
14 | 15 |
Version B
16 |
17 |
18 | ``` 19 | 20 | #### [Example](https://github.com/expert-m/react-split-testing/tree/master/examples/simple) ([Demo](https://expert-m.github.io/react-split-testing/)) 21 | 22 | ## Table Of Contents 23 | - [Installation](#installation) 24 | - [npm](#npm) 25 | - [yarn](#yarn) 26 | - [Usage](#usage) 27 | - [Server Rendering](#server-rendering) 28 | - [API Reference](#api-reference) 29 | - [``](#experiment-) 30 | - [``](#variant-) 31 | - [License](#license) 32 | 33 | ## Installation 34 | 35 | #### npm 36 | ```bash 37 | npm install react-split-testing 38 | ``` 39 | 40 | #### yarn 41 | ```bash 42 | yarn add react-split-testing 43 | ``` 44 | 45 | ## Usage 46 | 47 | ```jsx 48 | import { Experiment, Variant } from 'react-split-testing' 49 | 50 | class App extends Component { 51 | render() { 52 | return ( 53 | console.log(experimentName, variantName)} 57 | > 58 | 59 |
Section A
60 |
61 | 62 |
Section B
63 |
64 |
65 | ) 66 | } 67 | } 68 | ``` 69 | 70 | [back to top](#table-of-contents) 71 | 72 | --- 73 | 74 | ### Server Rendering 75 | A [``](#experiment-) with a `userIdentifier` property will choose a consistent [``](#variant-) suitable for server side rendering. 76 | 77 | #### Example 78 | 79 | ```jsx 80 | import { Experiment, Variant } from 'react-split-testing' 81 | 82 | class App extends Component { 83 | render() { 84 | return ( 85 | 86 | 87 |
Section A
88 |
89 | 90 |
Section B
91 |
92 |
93 | ) 94 | } 95 | } 96 | ``` 97 | 98 | [back to top](#table-of-contents) 99 | 100 | --- 101 | 102 | ## API Reference 103 | 104 | ### `` 105 | 106 | Experiment container component. Children must be of type [`Variant`](#variant-). 107 | 108 | * **Properties:** 109 | * `name` - Name of the experiment. 110 | * **Required** 111 | * **Type:** `string` 112 | * **Example:** `"My Example"` 113 | * `userIdentifier` - Distinct user identifier. Useful for [server side rendering](#server-rendering). 114 | * **Optional** 115 | * **Type:** `string` 116 | * **Example:** `"lMMaTqA8ODe7c36hhf2N"` 117 | * `variantName` - Name of the variant. When defined, this value is used to choose a variant. This property may be useful for [server side rendering](#server-rendering). 118 | * **Optional** 119 | * **Type:** `string` 120 | * **Example:** `"A"` 121 | * `onChoice` - Called when a [`Variant`](#variant-) has been chosen for your [`Experiment`](#experiment-). 122 | * **Optional** 123 | * **Type:** `function` 124 | * **Example:** `(experimentName, variantName) => { console.log(experimentName, variantName) }` 125 | * `onRawChoice` - Same as `onChoice`, but the objects passed are React component instances. 126 | * **Optional** 127 | * **Type:** `function` 128 | * **Example:** `(experiment, variant) => { console.log(experimentName.getName(), variant.props.name) }` 129 | 130 | * **Methods:** 131 | * `getName()` - Returns the [`Experiment`](#experiment-) name. 132 | * `getActiveVariant()` - Returns the currently displayed [`Variant`](#variant-). 133 | * `getActiveVariantName()` - Returns the currently displayed [`Variant`](#variant-) name. 134 | * `getVariant(variantName)` - Returns the instance of the specified [`Variant`](#variant-) name. 135 | 136 | [back to top](#table-of-contents) 137 | 138 | --- 139 | 140 | ### `` 141 | 142 | Variant container component. 143 | 144 | * **Properties:** 145 | * `name` - Name of the variant. 146 | * **Required** 147 | * **Type:** `string` 148 | * **Example:** `"A"` 149 | * `weight` - The variants will be chosen according to the ratio of the numbers, for example variants `A`, `B`, `C` with weights `1`, `2`, `2` will be chosen 20%, 40%, and 40% of the time, respectively. 150 | * **Optional** 151 | * **Type:** `number` 152 | * **Default:** `1` 153 | * **Example:** `2` 154 | 155 | * **Methods:** 156 | * `getName()` - Returns the [`Variant`](#variant-) name. 157 | * `getWeight()` - Returns the [`Variant`](#variant-) weight. 158 | 159 | [back to top](#table-of-contents) 160 | 161 | --- 162 | 163 | ## License 164 | MIT 165 | -------------------------------------------------------------------------------- /examples/simple/README.md: -------------------------------------------------------------------------------- 1 | # Example 2 | 3 | ## Start 4 | ```bash 5 | yarn start 6 | ``` 7 | 8 | #### [back to react-split-testing](https://github.com/expert-m/react-split-testing) 9 | -------------------------------------------------------------------------------- /examples/simple/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-split-testing-example", 3 | "homepage": "https://expert-m.github.io/react-split-testing", 4 | "version": "0.0.0", 5 | "license": "MIT", 6 | "private": true, 7 | "scripts": { 8 | "start": "react-scripts start", 9 | "build": "react-scripts build", 10 | "test": "react-scripts test --env=jsdom", 11 | "eject": "react-scripts eject" 12 | }, 13 | "dependencies": { 14 | "prop-types": "^15.6.2", 15 | "react": "^16.6.3", 16 | "react-dom": "^16.6.3", 17 | "react-scripts": "^3.0.1", 18 | "react-split-testing": "link:../.." 19 | }, 20 | "browserslist": [ 21 | ">0.2%", 22 | "not dead", 23 | "not ie <= 11", 24 | "not op_mini all" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /examples/simple/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | react-split-testing-example 6 | 7 | 8 | 9 | 12 | 13 |
14 | 15 | 16 | -------------------------------------------------------------------------------- /examples/simple/src/App.js: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import { Experiment, Variant } from 'react-split-testing' 3 | 4 | 5 | export default class App extends Component { 6 | constructor(props) { 7 | super(props) 8 | 9 | this.experiment = React.createRef() 10 | 11 | this.onClick = this.onClick.bind(this) 12 | this.onChoice = this.onChoice.bind(this) 13 | this.onRawChoice = this.onRawChoice.bind(this) 14 | } 15 | 16 | onClick() { 17 | const experiment = this.experiment.current 18 | 19 | alert( 20 | `Experiment name: "${experiment.getName()}"\n` + 21 | `Variant name: "${experiment.getActiveVariantName()}"` 22 | ) 23 | } 24 | 25 | onChoice(experimentName, variantName) { 26 | console.log( 27 | `Experiment name: "${experimentName}"\n` + 28 | `Variant name: "${variantName}"` 29 | ) 30 | } 31 | 32 | onRawChoice(experiment, variant) { 33 | console.log( 34 | `Experiment name: "${experiment.getName()}"\n` + 35 | `Variant name: "${variant.props.name}"\n` + 36 | `Variant weight: ${variant.props.weight}` 37 | ) 38 | } 39 | 40 | render() { 41 | return ( 42 |
43 |
44 |

Experiment #1

45 | 50 | 51 |
Section A
52 | 53 |
54 | 55 |
Section B
56 | 57 |
58 | 59 |
Section C
60 | 61 |
62 |
63 |
64 | 65 |
66 |

Experiment #2

67 | (userIdentifier="something") 68 | 73 | 74 |
Section A
75 |
76 | 77 |
Section B
78 |
79 |
80 |
81 | 82 |
83 |

Experiment #3

84 | (variantName="B") 85 | 86 | 87 |
Section A
88 |
89 | 90 |
Section B
91 |
92 |
93 |
94 | 95 | GitHub 101 |
102 | ) 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /examples/simple/src/index.css: -------------------------------------------------------------------------------- 1 | * { 2 | margin: 0; 3 | padding: 0; 4 | } 5 | 6 | body { 7 | font-family: Arial, sans-serif; 8 | background: white; 9 | } 10 | 11 | .App { 12 | text-align: center; 13 | } 14 | 15 | h1 { 16 | font-size: 1.5em; 17 | } 18 | 19 | .block { 20 | background: rgba(0, 0, 0, 0.1); 21 | width: 300px; 22 | border-radius: 10px; 23 | margin: 0 auto; 24 | padding: 10px; 25 | margin-top: 20px; 26 | } 27 | 28 | span { 29 | color: grey; 30 | margin-bottom: 10px; 31 | display: block; 32 | } 33 | 34 | button { 35 | outline: none; 36 | border: none; 37 | padding: 5px 10px; 38 | background: mediumblue; 39 | color: white; 40 | border-radius: 5px; 41 | margin-top: 10px; 42 | cursor: pointer; 43 | } 44 | 45 | .link { 46 | margin-top: 20px; 47 | color: black; 48 | display: block; 49 | } 50 | -------------------------------------------------------------------------------- /examples/simple/src/index.js: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import ReactDOM from 'react-dom' 3 | import App from './App' 4 | import './index.css' 5 | 6 | 7 | ReactDOM.render(, document.getElementById('root')) 8 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-split-testing", 3 | "version": "1.2.3", 4 | "description": "Simple A/B testing component for React.", 5 | "author": "expert-m", 6 | "license": "MIT", 7 | "repository": "expert-m/react-split-testing", 8 | "main": "dist/index.js", 9 | "module": "dist/index.es.js", 10 | "jsnext:main": "dist/index.es.js", 11 | "scripts": { 12 | "test": "jest --env=jsdom --setupTestFrameworkScriptFile=raf/polyfill", 13 | "test:watch": "jest --watchAll --env=jsdom --setupTestFrameworkScriptFile=raf/polyfill", 14 | "build": "rollup -c", 15 | "start": "rollup -c -w", 16 | "prepare": "yarn run build", 17 | "predeploy": "cd examples/simple && yarn install && yarn run build", 18 | "deploy": "gh-pages -d examples/simple/build" 19 | }, 20 | "keywords": [ 21 | "react", 22 | "ab", 23 | "a/b", 24 | "experiments", 25 | "testing", 26 | "ssr" 27 | ], 28 | "peerDependencies": { 29 | "prop-types": "^15.5.4", 30 | "react": "^15.0.0 || ^16.0.0" 31 | }, 32 | "devDependencies": { 33 | "@babel/cli": "^7.1.0", 34 | "@babel/core": "^7.1.6", 35 | "@babel/node": "^7.0.0", 36 | "@babel/plugin-proposal-class-properties": "^7.0.0", 37 | "@babel/plugin-proposal-decorators": "^7.1.6", 38 | "@babel/plugin-proposal-do-expressions": "^7.0.0", 39 | "@babel/plugin-proposal-export-default-from": "^7.0.0", 40 | "@babel/plugin-proposal-export-namespace-from": "^7.0.0", 41 | "@babel/plugin-proposal-function-bind": "^7.0.0", 42 | "@babel/plugin-proposal-function-sent": "^7.0.0", 43 | "@babel/plugin-proposal-json-strings": "^7.0.0", 44 | "@babel/plugin-proposal-logical-assignment-operators": "^7.0.0", 45 | "@babel/plugin-proposal-nullish-coalescing-operator": "^7.0.0", 46 | "@babel/plugin-proposal-numeric-separator": "^7.0.0", 47 | "@babel/plugin-proposal-optional-chaining": "^7.0.0", 48 | "@babel/plugin-proposal-pipeline-operator": "^7.0.0", 49 | "@babel/plugin-proposal-throw-expressions": "^7.0.0", 50 | "@babel/plugin-syntax-dynamic-import": "^7.0.0", 51 | "@babel/plugin-syntax-import-meta": "^7.0.0", 52 | "@babel/plugin-transform-runtime": "^7.1.0", 53 | "@babel/preset-env": "^7.1.6", 54 | "@babel/preset-react": "^7.0.0", 55 | "@babel/register": "^7.0.0", 56 | "@babel/runtime": "^7.0.0", 57 | "babel-eslint": "^10.0.2", 58 | "cross-env": "^5.2.0", 59 | "enzyme": "^3.7.0", 60 | "enzyme-adapter-react-16": "^1.7.0", 61 | "eslint": "^6.1.0", 62 | "eslint-config-standard": "^13.0.1", 63 | "eslint-config-standard-react": "^8.0.0", 64 | "eslint-plugin-import": "^2.14.0", 65 | "eslint-plugin-node": "^9.1.0", 66 | "eslint-plugin-promise": "^4.0.0", 67 | "eslint-plugin-react": "^7.14.3", 68 | "eslint-plugin-standard": "^4.0.0", 69 | "flux-standard-action": "^2.0.3", 70 | "gh-pages": "^2.0.0", 71 | "prop-types": "^15.6.2", 72 | "react": "^16.6.3", 73 | "react-dom": "^16.6.3", 74 | "react-scripts": "^3.0.1", 75 | "rollup": "^1.1.2", 76 | "rollup-plugin-babel": "^4.0.3", 77 | "rollup-plugin-commonjs": "^10.0.0", 78 | "rollup-plugin-node-resolve": "^5.0.1", 79 | "rollup-plugin-peer-deps-external": "^2.2.0", 80 | "rollup-plugin-postcss": "^2.0.0", 81 | "rollup-plugin-url": "^2.0.1" 82 | }, 83 | "files": [ 84 | "dist" 85 | ] 86 | } 87 | -------------------------------------------------------------------------------- /rollup.config.js: -------------------------------------------------------------------------------- 1 | import babel from 'rollup-plugin-babel' 2 | import commonjs from 'rollup-plugin-commonjs' 3 | import external from 'rollup-plugin-peer-deps-external' 4 | import postcss from 'rollup-plugin-postcss' 5 | import resolve from 'rollup-plugin-node-resolve' 6 | import url from 'rollup-plugin-url' 7 | 8 | import pkg from './package.json' 9 | 10 | 11 | export default { 12 | input: 'src/index.js', 13 | output: [ 14 | { 15 | file: pkg.main, 16 | format: 'cjs', 17 | sourcemap: true, 18 | }, 19 | { 20 | file: pkg.module, 21 | format: 'es', 22 | sourcemap: true, 23 | }, 24 | ], 25 | plugins: [ 26 | external(), 27 | postcss({ 28 | modules: true, 29 | }), 30 | url(), 31 | babel({ 32 | exclude: 'node_modules/**', 33 | runtimeHelpers: true, 34 | }), 35 | resolve(), 36 | commonjs({ 37 | include: 'node_modules/**', 38 | namedExports: { 39 | 'node_modules/react/index.js': ['Component', 'PureComponent', 'Fragment', 'Children', 'createElement'], 40 | }, 41 | }), 42 | ], 43 | } 44 | -------------------------------------------------------------------------------- /src/Experiment.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import weightedRandom from './utils/weightedRandom' 4 | import getUserIdentifier from './utils/getUserIdentifier' 5 | import getWeight from './utils/getWeight' 6 | 7 | 8 | export default class Experiment extends Component { 9 | static propTypes = { 10 | name: PropTypes.string.isRequired, 11 | onChoice: PropTypes.func, 12 | onRawChoice: PropTypes.func, 13 | userIdentifier: PropTypes.oneOfType([ 14 | PropTypes.string, 15 | PropTypes.number, 16 | ]), 17 | variantName: PropTypes.string, 18 | } 19 | 20 | constructor(props) { 21 | super(props) 22 | 23 | this.state = {} 24 | this.state.activeVariant = this._chooseVariant(true) 25 | } 26 | 27 | componentDidUpdate(prevProps) { 28 | const { name, children, userIdentifier, variantName } = this.props 29 | const isNewExperiment = ( 30 | prevProps.name !== name 31 | || prevProps.userIdentifier !== userIdentifier 32 | || prevProps.variantName !== variantName 33 | ) 34 | 35 | if (!isNewExperiment && prevProps.children === children) { 36 | return 37 | } 38 | 39 | this.setState({ 40 | activeVariant: this._chooseVariant(prevProps, isNewExperiment), 41 | }) 42 | } 43 | 44 | getActiveVariant() { 45 | return this.state.activeVariant 46 | } 47 | 48 | getActiveVariantName() { 49 | return this.state.activeVariant && this.state.activeVariant.props.name 50 | } 51 | 52 | getName() { 53 | return this.props.name 54 | } 55 | 56 | getVariant(variantName) { 57 | const children = React.Children.toArray(this.props.children) 58 | 59 | return children.find(element => ( 60 | this._isVariant(element) && element.props.name === variantName 61 | )) 62 | } 63 | 64 | _chooseVariant(isNewExperiment) { 65 | const { variantName, userIdentifier } = this.props 66 | 67 | if (variantName) { 68 | const variant = this.getVariant(variantName) 69 | 70 | if (isNewExperiment && variant) { 71 | this._onChoice(variant) 72 | } 73 | 74 | return variant 75 | } 76 | 77 | const children = React.Children.toArray(this.props.children) 78 | const activeVariantName = this.state.activeVariant && this.state.activeVariant.props.name 79 | const variants = [], weights = [] 80 | 81 | for (const element of children) { 82 | if (!this._isVariant(element)) { 83 | continue 84 | } 85 | 86 | if (!isNewExperiment && activeVariantName === element.props.name) { 87 | return element 88 | } 89 | 90 | variants.push(element) 91 | weights.push(getWeight(element.props.weight)) 92 | } 93 | 94 | const randomSeed = getUserIdentifier(userIdentifier) 95 | const index = weightedRandom(weights, randomSeed) 96 | 97 | if (index === -1) { 98 | return null 99 | } 100 | 101 | const variant = variants[index] 102 | this._onChoice(variant) 103 | 104 | return variant 105 | } 106 | 107 | _isVariant(element) { 108 | if (!React.isValidElement(element)) { 109 | return false 110 | } 111 | 112 | if (!element.type.isVariant) { 113 | throw new Error('Experiment children must be Variant components.') 114 | } 115 | 116 | return true 117 | } 118 | 119 | _onChoice(variant) { 120 | const { onChoice, onRawChoice, name } = this.props 121 | 122 | if (onChoice instanceof Function) { 123 | onChoice(name, variant.props.name) 124 | } 125 | 126 | if (onRawChoice instanceof Function) { 127 | onRawChoice(this, variant) 128 | } 129 | } 130 | 131 | render() { 132 | return this.state.activeVariant 133 | } 134 | } 135 | -------------------------------------------------------------------------------- /src/Variant.jsx: -------------------------------------------------------------------------------- 1 | import React, { Component } from 'react' 2 | import PropTypes from 'prop-types' 3 | import getWeight from './utils/getWeight' 4 | 5 | 6 | export default class Variant extends Component { 7 | static propTypes = { 8 | name: PropTypes.string.isRequired, 9 | weight: PropTypes.oneOfType([ 10 | PropTypes.string, 11 | PropTypes.number, 12 | ]), 13 | } 14 | 15 | static isVariant = true 16 | 17 | getName() { 18 | return this.props.name 19 | } 20 | 21 | getWeight() { 22 | return getWeight(this.props.weight) 23 | } 24 | 25 | render() { 26 | return this.props.children 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | import Experiment from './Experiment.jsx' 2 | import Variant from './Variant.jsx' 3 | 4 | 5 | export { 6 | Experiment, 7 | Variant, 8 | } 9 | -------------------------------------------------------------------------------- /src/tests/Experiment.test.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import { configure, shallow } from 'enzyme' 3 | import Experiment from '../Experiment' 4 | import Variant from '../Variant' 5 | import Adapter from 'enzyme-adapter-react-16' 6 | 7 | 8 | configure({ adapter: new Adapter() }) 9 | 10 | 11 | it('test variantName', () => { 12 | let name = '' 13 | 14 | const wrapper = shallow( 15 | name = `${a} ${b}`} 19 | > 20 | 21 |
test1
22 |
23 | test2 24 |
, 25 | ) 26 | 27 | expect(name).toEqual('Test B') 28 | expect(wrapper.contains(
test1
)).toEqual(false) 29 | expect(wrapper.contains(test2)).toEqual(true) 30 | }) 31 | 32 | it('test onChoice', () => { 33 | let name = '' 34 | 35 | const wrapper = shallow( 36 | name = `${a} ${b}`} 39 | userIdentifier={0} 40 | > 41 | 42 |
Test A
43 |
44 | 45 |
Test B
46 |
47 |
, 48 | ) 49 | 50 | expect(wrapper.contains(
{name}
)).toEqual(true) 51 | }) 52 | 53 | it('test onRawChoice', () => { 54 | let name = '' 55 | 56 | const wrapper = shallow( 57 | name = `${a.props.name} ${b.props.name}`} 60 | userIdentifier={0} 61 | > 62 | 63 |
Test A
64 |
65 | 66 |
Test B
67 |
68 |
, 69 | ) 70 | 71 | expect(wrapper.contains(
{name}
)).toEqual(true) 72 | }) 73 | 74 | it('test componentDidUpdate', () => { 75 | let name = '' 76 | const onChoice = (a, b) => name = `${a} ${b}` 77 | 78 | const wrapper = shallow( 79 | 80 | 81 |
A
82 |
83 | 84 |
B
85 |
86 |
, 87 | ) 88 | 89 | // First render 90 | expect(name).toEqual('Test A') 91 | expect(wrapper.contains(
A
)).toEqual(true) 92 | 93 | wrapper.setProps({ 94 | name: 'Test', 95 | onChoice, 96 | userIdentifier: 25, 97 | }) 98 | 99 | // With changed userIdentifier 100 | expect(name).toEqual('Test B') 101 | expect(wrapper.contains(
B
)).toEqual(true) 102 | 103 | wrapper.setProps({ 104 | name: 'Test2', 105 | onChoice, 106 | userIdentifier: 25, 107 | }) 108 | 109 | // With changed name 110 | expect(name).toEqual('Test2 B') 111 | expect(wrapper.contains(
B
)).toEqual(true) 112 | 113 | let flag = false 114 | wrapper.setProps({ 115 | name: 'Test2', 116 | onChoice: () => flag = true, 117 | userIdentifier: 25, 118 | }) 119 | 120 | // Not be called 121 | expect(flag).toEqual(false) 122 | expect(wrapper.contains(
B
)).toEqual(true) 123 | }) 124 | 125 | it('test weights', () => { 126 | let name = '' 127 | 128 | const wrapper = shallow( 129 | name = `${a} ${b}`} 132 | userIdentifier={24} 133 | > 134 | 135 |
A
136 |
137 | 138 |
B
139 |
140 |
, 141 | ) 142 | 143 | expect(wrapper.contains(
B
)).toEqual(true) 144 | }) 145 | 146 | 147 | it('test random', () => { 148 | const stats = { 'A': 0, 'B': 0, 'C': 0 } 149 | const onChoice = (a, b) => ++stats[b] 150 | 151 | for (let i = 0; i < 100; i++) { 152 | shallow( 153 | 154 | 155 |
A
156 |
157 | 158 |
B
159 |
160 | 161 |
C
162 |
163 |
, 164 | ) 165 | } 166 | 167 | expect(stats).toEqual({ A: 28, B: 55, C: 17 }) 168 | }) 169 | 170 | it('test empty experiment', () => { 171 | shallow() 172 | }) 173 | 174 | it('test empty variant', () => { 175 | shallow() 176 | }) 177 | 178 | it('test wrong variantName', () => { 179 | const wrapper = shallow( 180 | 181 | test1 182 | test2 183 | , 184 | ) 185 | 186 | expect(wrapper.contains('test1')).toEqual(false) 187 | expect(wrapper.contains('test2')).toEqual(false) 188 | }) 189 | -------------------------------------------------------------------------------- /src/utils/getUserIdentifier.js: -------------------------------------------------------------------------------- 1 | function generateIdentifier() { 2 | const possible = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' 3 | let text = '' 4 | 5 | for (let i = 0; i < 20; i++) { 6 | text += possible.charAt(Math.floor(Math.random() * possible.length)) 7 | } 8 | 9 | return text 10 | } 11 | 12 | export default function getIdentifier(userIdentifier) { 13 | if (userIdentifier || userIdentifier === 0) { 14 | return String(userIdentifier) 15 | } 16 | 17 | if (typeof window === 'undefined' || !('localStorage' in window)) { 18 | return null 19 | } 20 | 21 | const key = '__ab_experiment_identifier__' 22 | 23 | try { 24 | userIdentifier = localStorage.getItem(key) 25 | userIdentifier = userIdentifier && String(userIdentifier) 26 | 27 | if (!userIdentifier) { 28 | userIdentifier = generateIdentifier() 29 | localStorage.setItem(key, userIdentifier) 30 | } 31 | } catch (exception) { 32 | return null 33 | } 34 | 35 | return userIdentifier 36 | } 37 | -------------------------------------------------------------------------------- /src/utils/getWeight.js: -------------------------------------------------------------------------------- 1 | export default function getWeight(weight) { 2 | return (weight || weight === 0) ? parseFloat(weight) : 1 3 | } 4 | -------------------------------------------------------------------------------- /src/utils/weightedRandom.js: -------------------------------------------------------------------------------- 1 | function getRandom(seed) { 2 | let newSeed = 0 3 | 4 | for (let i = 0; i < seed.length; i++) { 5 | newSeed += seed.charCodeAt(i) * Math.pow(10, i) 6 | } 7 | 8 | const x = Math.sin(newSeed) * 10000 9 | return x - Math.floor(x) 10 | } 11 | 12 | 13 | export default function weightedRandom(weights, randomSeed) { // randomSeed must be a string 14 | if (!randomSeed) { 15 | return -1 16 | } 17 | 18 | let totalWeight = 0 19 | 20 | for (let i = 0; i < weights.length; i++) { 21 | totalWeight += weights[i] 22 | } 23 | 24 | let random = getRandom(randomSeed) * totalWeight 25 | 26 | for (let i = 0; i < weights.length; i++) { 27 | if (random < weights[i]) { 28 | return i 29 | } 30 | 31 | random -= weights[i] 32 | } 33 | 34 | return -1 35 | } 36 | --------------------------------------------------------------------------------