├── .circleci
└── config.yml
├── .editorconfig
├── .gitignore
├── .prettierrc
├── LICENSE
├── README.md
├── babel.config.js
├── codecov.yml
├── jest.config.js
├── package.json
├── src
├── __tests__
│ ├── hooks
│ │ ├── useExperiment.test.js
│ │ └── useExperimentAsync.test.js
│ ├── randomizer.test.js
│ └── toggleExperiment.test.js
├── constants.ts
├── index.ts
├── interfaces.ts
├── randomizer.ts
├── toggleExperiment.ts
├── useExperiment.ts
├── useExperimentAsync.ts
└── usePrevious.ts
├── tsconfig.json
├── tslint.json
├── types
└── fbjs.d.ts
└── yarn.lock
/.circleci/config.yml:
--------------------------------------------------------------------------------
1 | version: 2
2 | refs:
3 | container: &container
4 | docker:
5 | - image: node:8.16
6 | working_directory: ~/repo
7 | steps:
8 | - &Versions
9 | run:
10 | name: Versions
11 | command: node -v && npm -v && yarn -v
12 | - &Install
13 | run:
14 | name: Install Dependencies
15 | command: yarn install --pure-lockfile
16 | - &Build
17 | run:
18 | name: Build
19 | command: yarn build
20 | - &Lint
21 | run:
22 | name: Lint
23 | command: yarn lint
24 | - &Test
25 | run:
26 | name: Test
27 | command: yarn test --coverage && yarn codecov
28 |
29 | jobs:
30 | all:
31 | <<: *container
32 | steps:
33 | - checkout
34 | - *Versions
35 | - *Install
36 | - *Build
37 | - *Lint
38 | - *Test
39 |
40 | next:
41 | <<: *container
42 | steps:
43 | - checkout
44 | - *Versions
45 | - *Install
46 | - *Build
47 | - *Lint
48 | - *Test
49 |
50 | master:
51 | <<: *container
52 | steps:
53 | - checkout
54 | - *Versions
55 | - *Install
56 | - *Build
57 | - *Lint
58 | - *Test
59 |
60 | workflows:
61 | version: 2
62 | all:
63 | jobs:
64 | - all:
65 | context: global-env-vars
66 | filters:
67 | branches:
68 | ignore:
69 | - master
70 | - next
71 | next:
72 | jobs:
73 | - next:
74 | context: global-env-vars
75 | filters:
76 | branches:
77 | only: next
78 | master:
79 | jobs:
80 | - master:
81 | context: global-env-vars
82 | filters:
83 | branches:
84 | only: master
85 |
--------------------------------------------------------------------------------
/.editorconfig:
--------------------------------------------------------------------------------
1 | root = true
2 |
3 | # For most files
4 | [*]
5 | indent_size = 2
6 | indent_style = space
7 | end_of_line = lfit p
8 | charset = utf-8
9 | trim_trailing_whitespace = true
10 | insert_final_newline = true
11 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | .DS_Store
2 | lib
3 | node_modules
4 | coverage
5 | *.log
6 |
--------------------------------------------------------------------------------
/.prettierrc:
--------------------------------------------------------------------------------
1 | {
2 | "printWidth": 80,
3 | "tabWidth": 2,
4 | "useTabs": false,
5 | "semi": true,
6 | "singleQuote": false,
7 | "trailingComma": "none",
8 | "bracketSpacing": true,
9 | "jsxBracketSameLine": false,
10 | "fluid": false
11 | }
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2019 Julian Ustiyanovych
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 |
31 |
32 |
33 |
34 | ## Install
35 |
36 | Using npm:
37 |
38 | ```sh
39 | npm i ab-react-hook
40 | ```
41 |
42 | or using yarn:
43 |
44 | ```sh
45 | yarn add ab-react-hook
46 | ```
47 |
48 |
49 | ## When should I use A/B tests?
50 |
51 | It's a very good question to ask before start doing A/B tests. The simple answer would be - when the **sample size is statistically significant** and you have a **good traffic**. To dig deeper into numbers use [powercalculator](https://bookingcom.github.io/powercalculator/) made by [booking](https://bookingcom.github.io) to understand **how long would** it take you to run an A/B test and get a statistically significant result.
52 |
53 |
54 | ## ```useExperiment()``` [![][img-demo]](https://codesandbox.io/embed/ab-react-hook-playground-4crjn)
55 |
56 |
57 | - Define experiment variants and weights:
58 | ```
59 | variants: [{
60 | name: "control", weight: 50
61 | }, {
62 | name: "test", weight: 50
63 | }]
64 | ```
65 | You can define *as many variants as you want* but it is recommended to keep **two** or max **three** variants for your experiment.
66 | - Get the variant and send it to your analytics (_google analytics_, _facebook analytics_ etc.), so you can **aggregate results in a single place and analyze it later**.
67 |
68 |
69 | ```js
70 | const AddToCartButtonExperiment = () => {
71 | const experimentConfig = {
72 | id: "3143106091",
73 | name: "add-to-cart-green",
74 | variants: [{ name: "control", weight: 50 }, { name: "test", weight: 50 }]
75 | enableForceExperiment: true
76 | };
77 |
78 | const variant = useExperiment(experimentConfig)
79 |
80 | if (variant.name === "control") {
81 | return ;
82 | } else if (variant.name === "test") {
83 | return ;
84 | }
85 | }
86 | ```
87 |
88 |
89 | ## ```useExperimentAsync()```
90 |
91 | ```js
92 | const fetchVariant = () => {
93 | return new Promise((resolve, reject) => {
94 | setTimeout(() => {
95 | resolve("test");
96 | }, 2000);
97 | });
98 | }
99 |
100 | function AsyncAddToCartButtonExperiment() {
101 | const { variant, isLoading } = useExperimentAsync({
102 | name: "exp1",
103 | fetchVariant,
104 | enableForceExperiment: true
105 | });
106 |
107 | if (isLoading) {
108 | return loading...
;
109 | }
110 |
111 | if (variant.name === "control") {
112 | return ;
113 | } else if (variant.name === "test") {
114 | return ;
115 | }
116 | }
117 |
118 | ```
119 |
120 | ### Force experiment variant
121 |
122 | If `enableForceExperiment` flag set to `true` you can seamlessly **test** all possible variants of the particular experiment without changing the code.
123 |
124 |
125 | To **force** experiment variant add query parameter with **experiment id** and the **variant name**.
126 |
127 | - Force single experiment variant:
128 | ```
129 | /?et=exp_id:exp_variant
130 | ```
131 |
132 | - Force multiple experiments:
133 | ```
134 | /?et=exp_1:exp_variant_id,exp_2:exp_variant_id
135 | ```
136 |
137 | [img-demo]: https://img.shields.io/badge/demo-%20%20%20%F0%9F%9A%80-green.svg
138 |
--------------------------------------------------------------------------------
/babel.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | presets: [
3 | [
4 | "@babel/preset-env",
5 | {
6 | targets: {
7 | node: "current"
8 | }
9 | }
10 | ],
11 | "@babel/preset-react",
12 | "@babel/preset-typescript"
13 | ],
14 | env: {
15 | test: {
16 | plugins: ["dynamic-import-node"]
17 | },
18 | production: {
19 | plugins: ["@babel/plugin-syntax-dynamic-import"]
20 | }
21 | }
22 | }
23 |
--------------------------------------------------------------------------------
/codecov.yml:
--------------------------------------------------------------------------------
1 | codecov:
2 | notify:
3 | require_ci_to_pass: yes
4 |
5 | coverage:
6 | precision: 2
7 | round: down
8 | range: "70...100"
9 |
10 | status:
11 | project: yes
12 | patch: yes
13 | changes: no
14 |
15 | parsers:
16 | gcov:
17 | branch_detection:
18 | conditional: yes
19 | loop: yes
20 | method: no
21 | macro: no
22 |
23 | comment:
24 | layout: "header, diff"
25 | behavior: default
26 | require_changes: no
27 |
--------------------------------------------------------------------------------
/jest.config.js:
--------------------------------------------------------------------------------
1 | module.exports = {
2 | moduleFileExtensions: ["ts", "js", "json"],
3 | testEnvironment: "jest-environment-jsdom-global",
4 | coverageThreshold: {
5 | global: {
6 | branches: 83,
7 | functions: 100,
8 | lines: 100,
9 | statements: 100
10 | }
11 | }
12 | };
13 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "ab-react-hook",
3 | "version": "0.3.0",
4 | "description": "🧪A/B-Testing React Hook",
5 | "author": "Julian Ustiyanovych",
6 | "license": "MIT",
7 | "repository": {
8 | "type": "git",
9 | "url": "git@github.com:ju1i4n/ab-react-hook.git"
10 | },
11 | "keywords": [],
12 | "main": "lib/index.js",
13 | "types": "lib/index.d.ts",
14 | "scripts": {
15 | "test": "jest",
16 | "test:watch": "jest --watch",
17 | "lint": "tslint 'src/**/*.{ts,tsx}' -t verbose",
18 | "lint:fix": "tslint --project . --fix",
19 | "codecov": "codecov",
20 | "build": "rm -rf lib node_modules && yarn && tsc"
21 | },
22 | "files": [
23 | "lib/*"
24 | ],
25 | "husky": {
26 | "hooks": {
27 | "pre-commit": "yarn lint",
28 | "pre-push": "yarn test"
29 | }
30 | },
31 | "dependencies": {
32 | "@types/lodash.isequal": "^4.5.5",
33 | "@types/url-parse": "^1.4.3",
34 | "fbjs": "1.0.0",
35 | "lodash.isequal": "^4.5.0",
36 | "react": "16.8.6",
37 | "react-dom": "16.8.6",
38 | "url-parse": "^1.4.7",
39 | "uuid": "^3.3.2"
40 | },
41 | "devDependencies": {
42 | "@babel/core": "^7.4.5",
43 | "@babel/plugin-syntax-dynamic-import": "^7.2.0",
44 | "@babel/preset-env": "^7.4.5",
45 | "@babel/preset-react": "^7.0.0",
46 | "@babel/preset-typescript": "^7.3.3",
47 | "@types/react": "^16.8.19",
48 | "babel-plugin-dynamic-import-node": "^2.2.0",
49 | "codecov": "^3.5.0",
50 | "husky": "^2.4.0",
51 | "i": "^0.3.6",
52 | "jest": "24.8.0",
53 | "jest-environment-jsdom-global": "^1.2.0",
54 | "npm": "^6.9.0",
55 | "react-hooks-testing-library": "^0.5.1",
56 | "react-test-renderer": "16.8.6",
57 | "ts-jest": "^24.0.2",
58 | "ts-node": "^8.2.0",
59 | "tslint": "^5.17.0",
60 | "tslint-config-prettier": "^1.18.0",
61 | "tslint-plugin-prettier": "^2.0.1",
62 | "typescript": "^3.5.1"
63 | }
64 | }
65 |
--------------------------------------------------------------------------------
/src/__tests__/hooks/useExperiment.test.js:
--------------------------------------------------------------------------------
1 | import { renderHook } from "react-hooks-testing-library";
2 |
3 | import useExperiment from "../../useExperiment";
4 |
5 | describe("useExperiment", () => {
6 | let experimentConfig;
7 |
8 | beforeEach(() => {
9 | experimentConfig = {
10 | id: "31410601021",
11 | name: "exp1",
12 | variants: [{ name: "control", weight: 99 }, { name: "test", weight: 1 }]
13 | };
14 | });
15 |
16 | it("should return weighted variant", () => {
17 | const hook = renderHook(() => useExperiment(experimentConfig));
18 | const variant = hook.result.current;
19 |
20 | expect(variant).toEqual({ name: "control", weight: 99 });
21 | });
22 |
23 | it("should return forced variant", () => {
24 | jsdom.reconfigure({
25 | url: "https://example.com?et=exp1:test"
26 | });
27 |
28 | const hook = renderHook(() =>
29 | useExperiment({ ...experimentConfig, enableForceExperiment: true })
30 | );
31 |
32 | const { name } = hook.result.current;
33 |
34 | expect(name).toEqual("test");
35 | });
36 |
37 | it("should return default config variant when forced exp name is wrong", () => {
38 | jsdom.reconfigure({
39 | url: "https://example.com?et=exp_wrong:test"
40 | });
41 |
42 | const hook = renderHook(() =>
43 | useExperiment({ ...experimentConfig, enableForceExperiment: true })
44 | );
45 |
46 | const { name } = hook.result.current;
47 |
48 | expect(name).toEqual("control");
49 | });
50 | });
51 |
--------------------------------------------------------------------------------
/src/__tests__/hooks/useExperimentAsync.test.js:
--------------------------------------------------------------------------------
1 | import { act, renderHook } from "react-hooks-testing-library";
2 | import useExperimentAsync from "../../useExperimentAsync";
3 |
4 | describe("useExperimentAsync", () => {
5 | const fetchVariantFulfilled = () =>
6 | new Promise(resolve => {
7 | setTimeout(() => {
8 | resolve("test");
9 | }, 500);
10 | });
11 |
12 | const fetchVariantRejected = () =>
13 | new Promise((resolve, reject) => {
14 | setTimeout(() => {
15 | reject();
16 | }, 500);
17 | });
18 |
19 | it("when fetching variant set 'isLoading' to TRUE", () => {
20 | let hook;
21 |
22 | act(() => {
23 | hook = renderHook(() =>
24 | useExperimentAsync({
25 | name: "exp1",
26 | fetchVariant: fetchVariantFulfilled
27 | })
28 | );
29 | });
30 |
31 | expect(hook.result.current.isLoading).toBeTruthy();
32 | });
33 |
34 | it("when variant is resolved set 'isLoading' to FALSE", async () => {
35 | let hook;
36 |
37 | act(() => {
38 | hook = renderHook(() =>
39 | useExperimentAsync({
40 | name: "exp1",
41 | fetchVariant: fetchVariantFulfilled
42 | })
43 | );
44 | });
45 |
46 | await hook.waitForNextUpdate();
47 |
48 | expect(hook.result.current.isLoading).toBeFalsy();
49 | });
50 |
51 | it("fetch variant with success - resolve", async () => {
52 | let hook;
53 |
54 | act(() => {
55 | hook = renderHook(() =>
56 | useExperimentAsync({
57 | name: "exp1",
58 | fetchVariant: fetchVariantFulfilled
59 | })
60 | );
61 | });
62 |
63 | await hook.waitForNextUpdate();
64 |
65 | expect(hook.result.current.isLoading).toBeFalsy();
66 | expect(hook.result.current.variant).toEqual("test");
67 | });
68 |
69 | it("fetch variant with failure - reject", async () => {
70 | let hook;
71 |
72 | act(() => {
73 | hook = renderHook(() =>
74 | useExperimentAsync({
75 | name: "exp1",
76 | fetchVariant: fetchVariantRejected
77 | })
78 | );
79 | });
80 |
81 | await hook.waitForNextUpdate();
82 |
83 | expect(hook.result.current.isLoading).toBeFalsy();
84 | expect(hook.result.current.variant).toEqual("noneVariant");
85 | });
86 |
87 | it("fetch forced variant from the url", async () => {
88 | jsdom.reconfigure({
89 | url: "https://example.com?et=exp1:super_test"
90 | });
91 |
92 | let hook;
93 |
94 | act(() => {
95 | hook = renderHook(() =>
96 | useExperimentAsync({
97 | name: "exp1",
98 | fetchVariant: fetchVariantFulfilled,
99 | enableForceExperiment: true
100 | })
101 | );
102 | });
103 |
104 | await hook.waitForNextUpdate();
105 |
106 | expect(hook.result.current.isLoading).toBeFalsy();
107 | expect(hook.result.current.variant).toEqual("super_test");
108 | });
109 | });
110 |
--------------------------------------------------------------------------------
/src/__tests__/randomizer.test.js:
--------------------------------------------------------------------------------
1 | import uuidv4 from "uuid/v4";
2 |
3 | import { generateWeightedVariant } from "../randomizer";
4 |
5 | describe("Randomization algorithm for weighted variants:", () => {
6 | let n;
7 | beforeAll(() => {
8 | /*
9 | * Increase "n", to have larger destribution (e.g. 1K or 10K or 100K or 1M).
10 | * ⛔ ATTENTION ⛔: large number is not recommended because it would increase a time to
11 | * run all tests. 1M would take +/- 15sec.
12 | */
13 | n = 100;
14 | });
15 |
16 | describe("A/B: randomisation algorithm output should be reasonable.", () => {
17 | it("50/50 split", () => {
18 | const { isErrorRateLte1Precent } = calcABVariantWeights(
19 | {
20 | weightA: 50,
21 | weightB: 50
22 | },
23 | n
24 | );
25 |
26 | expect(isErrorRateLte1Precent).toBeTruthy();
27 | });
28 |
29 | it("60/40 split", () => {
30 | const { isErrorRateLte1Precent } = calcABVariantWeights(
31 | {
32 | weightA: 60,
33 | weightB: 40
34 | },
35 | n
36 | );
37 |
38 | expect(isErrorRateLte1Precent).toBeTruthy();
39 | });
40 |
41 | it("80/20 split", () => {
42 | const { isErrorRateLte1Precent } = calcABVariantWeights(
43 | {
44 | weightA: 80,
45 | weightB: 20
46 | },
47 | n
48 | );
49 |
50 | expect(isErrorRateLte1Precent).toBeTruthy();
51 | });
52 |
53 | it("99/1 split", () => {
54 | const { isErrorRateLte1Precent } = calcABVariantWeights(
55 | {
56 | weightA: 99,
57 | weightB: 1
58 | },
59 | n
60 | );
61 |
62 | expect(isErrorRateLte1Precent).toBeTruthy();
63 | });
64 | });
65 |
66 | describe("A/B/C: randomisation algorithm output should be reasonable", () => {
67 | it("33/33/33 split", () => {
68 | const { isErrorRateLte1Precent } = calcABCVariantWeights(
69 | {
70 | weightA: 33,
71 | weightB: 33,
72 | weightC: 34
73 | },
74 | n
75 | );
76 |
77 | expect(isErrorRateLte1Precent).toBeTruthy();
78 | });
79 |
80 | it("60/20/20 split", () => {
81 | const { isErrorRateLte1Precent } = calcABCVariantWeights(
82 | {
83 | weightA: 60,
84 | weightB: 20,
85 | weightC: 20
86 | },
87 | n
88 | );
89 |
90 | expect(isErrorRateLte1Precent).toBeTruthy();
91 | });
92 |
93 | it("40/40/20 split", () => {
94 | const { isErrorRateLte1Precent } = calcABCVariantWeights(
95 | {
96 | weightA: 40,
97 | weightB: 40,
98 | weightC: 20
99 | },
100 | n
101 | );
102 |
103 | expect(isErrorRateLte1Precent).toBeTruthy();
104 | });
105 |
106 | it("80/10/10 split", () => {
107 | const { isErrorRateLte1Precent } = calcABCVariantWeights(
108 | {
109 | weightA: 40,
110 | weightB: 40,
111 | weightC: 20
112 | },
113 | n
114 | );
115 |
116 | expect(isErrorRateLte1Precent).toBeTruthy();
117 | });
118 | });
119 |
120 | describe("return 'noneVariant' if configuration is wrong - only one variant provided.", () => {
121 | let variant = generateWeightedVariant({
122 | id: `${uuidv4()}`,
123 | name: "experiment",
124 | variants: [
125 | {
126 | name: "a",
127 | weight: 50
128 | }
129 | ]
130 | });
131 |
132 | expect(variant).toEqual({ name: "noneVariant", weight: undefined });
133 | });
134 |
135 | describe("return 'noneVariant' if configuration is wrong - no variants provided.", () => {
136 | let variant = generateWeightedVariant({
137 | id: `${uuidv4()}`,
138 | name: "experiment",
139 | variants: [
140 | {
141 | name: "a",
142 | weight: 50
143 | },
144 | {
145 | name: "a",
146 | weight: 51
147 | }
148 | ]
149 | });
150 |
151 | expect(variant).toEqual({ name: "noneVariant", weight: undefined });
152 | });
153 |
154 | describe("return 'noneVariant' if configuration is wrong - total sum of all weights != 100.", () => {
155 | let variant = generateWeightedVariant({
156 | id: `${uuidv4()}`,
157 | name: "experiment",
158 | variants: []
159 | });
160 |
161 | expect(variant).toEqual({ name: "noneVariant", weight: undefined });
162 | });
163 |
164 | describe("given the same input, randomizer should always return deterministic result - same variant", () => {
165 | const assignedVariantWithMultipleSessions = [];
166 |
167 | for (let index = 0; index < 100; index++) {
168 | let variant = generateWeightedVariant({
169 | id: `user_id_123456789`,
170 | name: "experiment",
171 | variants: [
172 | {
173 | name: "test",
174 | weight: 50
175 | },
176 | {
177 | name: "control",
178 | weight: 50
179 | }
180 | ]
181 | });
182 |
183 | assignedVariantWithMultipleSessions.push(variant);
184 | }
185 |
186 | const isVariantDeterministic = assignedVariantWithMultipleSessions.every(
187 | ({ name, weight }) => name === "control" && weight === 50
188 | );
189 |
190 | expect(isVariantDeterministic).toBeTruthy();
191 | });
192 | });
193 |
194 | const calcABVariantWeights = ({ weightA, weightB }, n) => {
195 | const assignedVariants = [];
196 |
197 | for (let index = 0; index < n; index++) {
198 | let variant = generateWeightedVariant({
199 | id: `${uuidv4()}`,
200 | name: "experiment",
201 | variants: [
202 | {
203 | name: "a",
204 | weight: weightA
205 | },
206 | {
207 | name: "b",
208 | weight: weightB
209 | }
210 | ]
211 | });
212 |
213 | assignedVariants.push(variant.name);
214 | }
215 |
216 | const vAFraction = assignedVariants.filter(v => v === "a").length / n;
217 | const vBFraction = assignedVariants.filter(v => v === "b").length / n;
218 |
219 | const weight = Math.sqrt((weightA * weightB) / n);
220 |
221 | // with 99.7% probability for Variant A: p - 3 * s < f < p + 3 * s
222 | const p99A =
223 | weightA - 3 * weight < vAFraction < weightA < weightA + 3 * weight;
224 | // with 99.7% probability for Variant B: p - 3 * s < f < p + 3 * s
225 | const p99B =
226 | weightB - 3 * weight < vBFraction < weightB < weightB + 3 * weight;
227 |
228 | // Calculate error rate for variant A. error = (approx - exact) / exact;
229 | const vAErrorRate = (100 - vAFraction) / 100;
230 |
231 | // Calculate error rate for variant B. error = (approx - exact) / exact;
232 | const vBErrorRate = (100 - vBFraction) / 100;
233 |
234 | // Error rate must be less or equal to 1%
235 | const isErrorRateLte1Precent = vAErrorRate <= 1 && vBErrorRate <= 1;
236 |
237 | return {
238 | isErrorRateLte1Precent,
239 | vAFraction,
240 | vBFraction,
241 | vAErrorRate,
242 | vBErrorRate,
243 | p99A,
244 | p99B
245 | };
246 | };
247 |
248 | const calcABCVariantWeights = ({ weightA, weightB, weightC }, n) => {
249 | const assignedVariants = [];
250 |
251 | for (let index = 0; index < n; index++) {
252 | let variant = generateWeightedVariant({
253 | id: `${uuidv4()}`,
254 | name: "experiment",
255 | variants: [
256 | {
257 | name: "a",
258 | weight: weightA
259 | },
260 | {
261 | name: "b",
262 | weight: weightB
263 | },
264 | {
265 | name: "c",
266 | weight: weightC
267 | }
268 | ]
269 | });
270 |
271 | assignedVariants.push(variant.name);
272 | }
273 |
274 | const vAFraction = assignedVariants.filter(v => v === "a").length / n;
275 | const vBFraction = assignedVariants.filter(v => v === "b").length / n;
276 | const vCFraction = assignedVariants.filter(v => v === "c").length / n;
277 |
278 | const weight = Math.sqrt((weightA * weightB * weightC) / n);
279 |
280 | // with 99.7% probability for Variant A: p - 3 * s < f < p + 3 * s
281 | const p99A =
282 | weightA - 3 * weight < vAFraction < weightA < weightA + 3 * weight;
283 | // with 99.7% probability for Variant B: p - 3 * s < f < p + 3 * s
284 | const p99B =
285 | weightB - 3 * weight < vBFraction < weightB < weightB + 3 * weight;
286 | // with 99.7% probability for Variant C: p - 3 * s < f < p + 3 * s
287 | const p99C =
288 | weightC - 3 * weight < vCFraction < weightC < weightC + 3 * weight;
289 |
290 | // Calculate error rate for variant A. error = (approx - exact) / exact;
291 | const vAErrorRate = (100 - vAFraction) / 100;
292 | // Calculate error rate for variant B. error = (approx - exact) / exact;
293 | const vBErrorRate = (100 - vBFraction) / 100;
294 | // Calculate error rate for variant C. error = (approx - exact) / exact;
295 | const vCErrorRate = (100 - vCFraction) / 100;
296 |
297 | // Error rate must be less or equal to 1%
298 | const isErrorRateLte1Precent =
299 | vAErrorRate <= 1 && vBErrorRate <= 1 && vCErrorRate <= 1;
300 |
301 | return {
302 | isErrorRateLte1Precent,
303 | vAFraction,
304 | vBFraction,
305 | vCFraction,
306 | vAErrorRate,
307 | vBErrorRate,
308 | vCErrorRate,
309 | p99A,
310 | p99B,
311 | p99C
312 | };
313 | };
314 |
--------------------------------------------------------------------------------
/src/__tests__/toggleExperiment.test.js:
--------------------------------------------------------------------------------
1 | import { getBrowserQueryExperimentNames } from "../toggleExperiment";
2 |
3 | describe("Force experiment variant", () => {
4 | it("should return forced variant when defined in a querystring", () => {
5 | jsdom.reconfigure({
6 | url: "https://example.com?et=exp1:test"
7 | });
8 |
9 | expect(window.location.search).toEqual("?et=exp1:test");
10 |
11 | const forcedVariants = getBrowserQueryExperimentNames();
12 | const variant = forcedVariants["exp1"];
13 |
14 | expect(variant).toEqual("test");
15 | });
16 |
17 | it("should return undefined variant when variant is not forced", () => {
18 | jsdom.reconfigure({
19 | url: "https://example.com"
20 | });
21 |
22 | const forcedVariants = getBrowserQueryExperimentNames();
23 |
24 | expect(forcedVariants).toEqual({});
25 | });
26 | });
27 |
--------------------------------------------------------------------------------
/src/constants.ts:
--------------------------------------------------------------------------------
1 | export const NONE_VARIANT = "noneVariant";
2 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | import useExperiment from "./useExperiment";
2 | import useExperimentAsync from "./useExperimentAsync";
3 |
4 | export {
5 | useExperiment,
6 | useExperimentAsync
7 | };
8 |
--------------------------------------------------------------------------------
/src/interfaces.ts:
--------------------------------------------------------------------------------
1 | export interface Variant {
2 | name: string;
3 | weight: number;
4 | }
5 |
6 | export interface NoneVariant {
7 | name: string;
8 | }
9 | export interface ExperimentConfigAsync {
10 | name: string;
11 | fetchVariant: (...args: any[] | []) => Promise;
12 | enableForceExperiment?: boolean;
13 | }
14 |
15 | export interface ExperimentResultAsync {
16 | variant: string;
17 | isLoading?: boolean;
18 | }
19 |
20 | export interface ExperimentConfig {
21 | id: string;
22 | name: string;
23 | variants: Variant[];
24 | enableForceExperiment?: boolean;
25 | }
26 |
--------------------------------------------------------------------------------
/src/randomizer.ts:
--------------------------------------------------------------------------------
1 | import crc32 from "fbjs/lib/crc32";
2 | import { NONE_VARIANT } from "./constants";
3 | import { ExperimentConfig, Variant } from "./interfaces";
4 |
5 | export const generateWeightedVariant = (
6 | options: ExperimentConfig,
7 | logger: any = console
8 | ): Variant => {
9 | const { id, name, variants } = options;
10 |
11 | if (variants.length < 2) {
12 | /*
13 | * ✅Experiment configuration check.
14 | *
15 | * At least two variants should be provided (e.g. A and B).
16 | */
17 | logger.warn(
18 | `WARN: There are should be at least TWO experiment variants and you have: ${
19 | variants.length
20 | }.`
21 | );
22 |
23 | return { name: NONE_VARIANT, weight: undefined };
24 | }
25 |
26 | const weightSum = variants.reduce((acc, variant) => acc + variant.weight, 0);
27 |
28 | if (weightSum !== 100) {
29 | /*
30 | * ✅Experiment configuration check.
31 | *
32 | * The total sum of variants weight should be equal to 100.
33 | * If the sum is not equal to 100, return noneVariant to indicate
34 | * experiment missconfiguration.
35 | */
36 | const sign = weightSum < 100 ? "less" : "more";
37 |
38 | logger.warn(
39 | `WARN: Your total variant weight is ${sign} than 100.` +
40 | " " +
41 | "Make sure your experiemnt configuration is correct."
42 | );
43 |
44 | return { name: NONE_VARIANT, weight: undefined };
45 | }
46 |
47 | const bucket = Math.abs(crc32(`${id}${name}`) % weightSum);
48 | const variant = findVariant(0, bucket, variants, logger);
49 |
50 | return { name: variant.name, weight: variant.weight };
51 | };
52 |
53 | const findVariant = (
54 | currentBucket: number,
55 | initialBucket: number,
56 | variants: Variant[],
57 | logger: any = console
58 | ): Variant => {
59 | const variant = variants.shift() as Variant;
60 |
61 | return initialBucket < currentBucket + variant.weight || !variants.length
62 | ? variant
63 | : findVariant(currentBucket + variant.weight, initialBucket, variants, logger);
64 | };
65 |
--------------------------------------------------------------------------------
/src/toggleExperiment.ts:
--------------------------------------------------------------------------------
1 | import URL from "url-parse";
2 |
3 | interface Experiment {
4 | [key: string]: string | undefined;
5 | }
6 |
7 | const getQueryExperiments = (et: string): Experiment =>
8 | et
9 | ? et.split(",").reduce((exps, exp) => {
10 | const [expId, expVariant] = exp.split(":");
11 | exps[expId] = expVariant;
12 | return exps;
13 | }, {} as Experiment)
14 | : {};
15 |
16 | export const getBrowserQueryExperimentNames = (search?: string): Experiment => {
17 | search = typeof window === "undefined" ? "" : window.location.search;
18 | const { et = "" } = URL.qs.parse(search) as Record;
19 | return getQueryExperiments(et);
20 | };
21 |
--------------------------------------------------------------------------------
/src/useExperiment.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 |
3 | import isEqual from "lodash.isequal";
4 |
5 | import usePrevious from "./usePrevious";
6 |
7 | import { ExperimentConfig, Variant } from "./interfaces";
8 | import { generateWeightedVariant } from "./randomizer";
9 | import { getBrowserQueryExperimentNames } from "./toggleExperiment";
10 |
11 | const useExperiment = (
12 | config: ExperimentConfig,
13 | logger: any = console,
14 | ): Variant | undefined => {
15 |
16 | const defaultVariant = {
17 | name: undefined,
18 | weight: undefined
19 | } as Variant;
20 |
21 | const [variant, setVariant] = useState(defaultVariant);
22 |
23 | const { id, name, variants, enableForceExperiment } = config;
24 |
25 | const prevConfig = usePrevious([ id, name, variants, enableForceExperiment ]);
26 | const prevVariant = usePrevious([ variant ]);
27 |
28 | useEffect(() => {
29 | if (
30 | isEqual(prevConfig, [
31 | id,
32 | name,
33 | variants,
34 | enableForceExperiment
35 | ]) &&
36 | !isEqual(prevVariant, [defaultVariant])
37 | ) {
38 | return;
39 | }
40 |
41 | if (enableForceExperiment) {
42 | const forcedExperiments = getBrowserQueryExperimentNames();
43 | const forcedVariant = forcedExperiments[name];
44 |
45 | if (forcedVariant) {
46 | setVariant({ name: forcedVariant, weight: undefined });
47 | return;
48 | }
49 | }
50 |
51 | const variant = generateWeightedVariant(
52 | {
53 | id,
54 | name,
55 | variants
56 | } as ExperimentConfig,
57 | logger
58 | );
59 |
60 | setVariant(variant);
61 | });
62 |
63 | return variant;
64 | };
65 |
66 | export default useExperiment;
67 |
--------------------------------------------------------------------------------
/src/useExperimentAsync.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useState } from "react";
2 | import { NONE_VARIANT } from "./constants";
3 | import { ExperimentConfigAsync, ExperimentResultAsync } from "./interfaces";
4 | import { getBrowserQueryExperimentNames } from "./toggleExperiment";
5 |
6 | export default function useExperimentAsync(
7 | config: ExperimentConfigAsync,
8 | logger: any = console
9 | ): ExperimentResultAsync {
10 | const [variant, setVariant] = useState("");
11 | const [isLoading, setLoading] = useState(false);
12 |
13 | const { name, fetchVariant, enableForceExperiment } = config;
14 |
15 | useEffect(() => {
16 | if (enableForceExperiment) {
17 | (async () => {
18 | const forcedExperiments = getBrowserQueryExperimentNames();
19 | const forcedVariant = await Promise.resolve(forcedExperiments[name]);
20 |
21 | if (forcedVariant) {
22 | setLoading(true);
23 | setVariant(forcedVariant);
24 | setLoading(false);
25 | return;
26 | }
27 | })();
28 | }
29 |
30 | (async () => {
31 | try {
32 | setLoading(true);
33 | const variant = await fetchVariant();
34 | setVariant(variant);
35 | setLoading(false);
36 | } catch (err) {
37 | logger.error(err);
38 | setLoading(false);
39 | setVariant(NONE_VARIANT);
40 | }
41 | })();
42 | }, [fetchVariant, enableForceExperiment, logger]);
43 |
44 | return { variant, isLoading };
45 | }
46 |
--------------------------------------------------------------------------------
/src/usePrevious.ts:
--------------------------------------------------------------------------------
1 | import { useEffect, useRef } from "react";
2 |
3 | const usePrevious = (state: T): T | undefined => {
4 | const ref = useRef();
5 |
6 | useEffect(() => {
7 | ref.current = state;
8 | });
9 |
10 | return ref.current;
11 | };
12 |
13 | export default usePrevious;
14 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compilerOptions": {
3 | "baseUrl": ".",
4 | "paths": { "*": ["types/*"] },
5 | "declaration": true,
6 | "declarationDir": "./lib",
7 | "target": "es2017",
8 | "module": "commonjs",
9 | "sourceMap": false,
10 | "pretty": true,
11 | "rootDir": "./src",
12 | "outDir": "./lib",
13 | "noImplicitAny": true,
14 | "noImplicitThis": true,
15 | "alwaysStrict": true,
16 | "noImplicitReturns": true,
17 | "moduleResolution": "node",
18 | "typeRoots": ["./types", "./node_modules/@types"],
19 | "esModuleInterop": true
20 | },
21 | "exclude": ["node_modules", "lib", "**/__tests__/**/*"]
22 | }
--------------------------------------------------------------------------------
/tslint.json:
--------------------------------------------------------------------------------
1 | {
2 | "extends": [
3 | "tslint:recommended"
4 | ],
5 | "rules": {
6 | "arrow-parens": false,
7 | "no-shadowed-variable": false,
8 | "interface-name": false,
9 | "trailing-comma": false,
10 | "object-literal-sort-keys": false,
11 | "max-classes-per-file": [false, 0]
12 | }
13 | }
14 |
--------------------------------------------------------------------------------
/types/fbjs.d.ts:
--------------------------------------------------------------------------------
1 | declare module 'fbjs/lib/crc32';
--------------------------------------------------------------------------------