@variantlab/core 0.1.4 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1209 -39
- package/docs/API.md +692 -0
- package/docs/ARCHITECTURE.md +430 -0
- package/docs/CONTRIBUTING.md +264 -0
- package/docs/ROADMAP.md +292 -0
- package/docs/SECURITY.md +323 -0
- package/docs/design/api-philosophy.md +347 -0
- package/docs/design/config-format.md +442 -0
- package/docs/design/design-principles.md +212 -0
- package/docs/design/targeting-dsl.md +433 -0
- package/docs/features/codegen.md +351 -0
- package/docs/features/crash-rollback.md +399 -0
- package/docs/features/debug-overlay.md +328 -0
- package/docs/features/hmac-signing.md +330 -0
- package/docs/features/killer-features.md +308 -0
- package/docs/features/multivariate.md +339 -0
- package/docs/features/qr-sharing.md +372 -0
- package/docs/features/targeting.md +481 -0
- package/docs/features/time-travel.md +306 -0
- package/docs/features/value-experiments.md +487 -0
- package/docs/phases/phase-2-expansion.md +307 -0
- package/docs/phases/phase-3-ecosystem.md +289 -0
- package/docs/phases/phase-4-advanced.md +306 -0
- package/docs/phases/phase-5-v1-stable.md +350 -0
- package/docs/research/bundle-size-analysis.md +279 -0
- package/docs/research/competitors.md +327 -0
- package/docs/research/framework-ssr-quirks.md +394 -0
- package/docs/research/naming-rationale.md +238 -0
- package/docs/research/origin-story.md +179 -0
- package/docs/research/security-threats.md +312 -0
- package/package.json +2 -1
|
@@ -0,0 +1,487 @@
|
|
|
1
|
+
# Value experiments
|
|
2
|
+
|
|
3
|
+
How to use variantlab as a feature-flag / remote-config tool. Value experiments return values, not components.
|
|
4
|
+
|
|
5
|
+
## Table of contents
|
|
6
|
+
|
|
7
|
+
- [The two experiment types](#the-two-experiment-types)
|
|
8
|
+
- [When to use value experiments](#when-to-use-value-experiments)
|
|
9
|
+
- [Defining a value experiment](#defining-a-value-experiment)
|
|
10
|
+
- [Reading the value](#reading-the-value)
|
|
11
|
+
- [Type inference](#type-inference)
|
|
12
|
+
- [Supported value types](#supported-value-types)
|
|
13
|
+
- [Feature flag patterns](#feature-flag-patterns)
|
|
14
|
+
- [Remote config patterns](#remote-config-patterns)
|
|
15
|
+
- [Gotchas](#gotchas)
|
|
16
|
+
|
|
17
|
+
---
|
|
18
|
+
|
|
19
|
+
## The two experiment types
|
|
20
|
+
|
|
21
|
+
variantlab supports two kinds of experiments:
|
|
22
|
+
|
|
23
|
+
| Type | Purpose | Hook | Component |
|
|
24
|
+
|---|---|---|---|
|
|
25
|
+
| `"render"` | Swap components | `useVariant` | `<Variant>` |
|
|
26
|
+
| `"value"` | Return values | `useVariantValue` | `<VariantValue>` |
|
|
27
|
+
|
|
28
|
+
They share the same config format, targeting, rollback, and debug UX. The only difference is the `type` field and whether variants carry a `value`.
|
|
29
|
+
|
|
30
|
+
---
|
|
31
|
+
|
|
32
|
+
## When to use value experiments
|
|
33
|
+
|
|
34
|
+
Use `type: "value"` when:
|
|
35
|
+
|
|
36
|
+
- The experiment changes **data**, not structure (copy, colors, numbers, URLs, feature flags)
|
|
37
|
+
- You want to read the value in multiple places without duplicating render logic
|
|
38
|
+
- The variants are primitives, arrays, or JSON objects
|
|
39
|
+
- You want a classic "feature flag" API
|
|
40
|
+
|
|
41
|
+
Use `type: "render"` when:
|
|
42
|
+
|
|
43
|
+
- The experiment changes **components** (different layouts, different UIs)
|
|
44
|
+
- You want exhaustive type checking on variant → component mapping
|
|
45
|
+
- Each variant has its own JSX tree
|
|
46
|
+
|
|
47
|
+
---
|
|
48
|
+
|
|
49
|
+
## Defining a value experiment
|
|
50
|
+
|
|
51
|
+
```json
|
|
52
|
+
{
|
|
53
|
+
"id": "cta-copy",
|
|
54
|
+
"type": "value",
|
|
55
|
+
"default": "buy-now",
|
|
56
|
+
"variants": [
|
|
57
|
+
{ "id": "buy-now", "value": "Buy now" },
|
|
58
|
+
{ "id": "get-started", "value": "Get started" },
|
|
59
|
+
{ "id": "try-free", "value": "Try it free" }
|
|
60
|
+
]
|
|
61
|
+
}
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
- `type: "value"` is required (or it will be treated as a render experiment)
|
|
65
|
+
- Each variant MUST have a `value` field
|
|
66
|
+
- `value` can be any JSON-serializable type (string, number, boolean, array, object, null)
|
|
67
|
+
- All variants should have the same value shape (enforced by codegen, not the runtime)
|
|
68
|
+
|
|
69
|
+
### A boolean feature flag
|
|
70
|
+
|
|
71
|
+
```json
|
|
72
|
+
{
|
|
73
|
+
"id": "ai-assistant-enabled",
|
|
74
|
+
"type": "value",
|
|
75
|
+
"default": "off",
|
|
76
|
+
"variants": [
|
|
77
|
+
{ "id": "off", "value": false },
|
|
78
|
+
{ "id": "on", "value": true }
|
|
79
|
+
]
|
|
80
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
### A number
|
|
84
|
+
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"id": "max-articles-per-page",
|
|
88
|
+
"type": "value",
|
|
89
|
+
"default": "default",
|
|
90
|
+
"variants": [
|
|
91
|
+
{ "id": "default", "value": 10 },
|
|
92
|
+
{ "id": "larger", "value": 20 },
|
|
93
|
+
{ "id": "smaller", "value": 5 }
|
|
94
|
+
]
|
|
95
|
+
}
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### A JSON object
|
|
99
|
+
|
|
100
|
+
```json
|
|
101
|
+
{
|
|
102
|
+
"id": "homepage-layout-config",
|
|
103
|
+
"type": "value",
|
|
104
|
+
"default": "v1",
|
|
105
|
+
"variants": [
|
|
106
|
+
{
|
|
107
|
+
"id": "v1",
|
|
108
|
+
"value": {
|
|
109
|
+
"heroVisible": true,
|
|
110
|
+
"trendingCount": 10,
|
|
111
|
+
"sidebarAds": false
|
|
112
|
+
}
|
|
113
|
+
},
|
|
114
|
+
{
|
|
115
|
+
"id": "v2",
|
|
116
|
+
"value": {
|
|
117
|
+
"heroVisible": false,
|
|
118
|
+
"trendingCount": 20,
|
|
119
|
+
"sidebarAds": true
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
]
|
|
123
|
+
}
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
### An array
|
|
127
|
+
|
|
128
|
+
```json
|
|
129
|
+
{
|
|
130
|
+
"id": "feature-order",
|
|
131
|
+
"type": "value",
|
|
132
|
+
"default": "default",
|
|
133
|
+
"variants": [
|
|
134
|
+
{ "id": "default", "value": ["news", "videos", "opinions"] },
|
|
135
|
+
{ "id": "reordered", "value": ["videos", "news", "opinions"] }
|
|
136
|
+
]
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
---
|
|
141
|
+
|
|
142
|
+
## Reading the value
|
|
143
|
+
|
|
144
|
+
### Hook
|
|
145
|
+
|
|
146
|
+
```ts
|
|
147
|
+
import { useVariantValue } from "@variantlab/react";
|
|
148
|
+
|
|
149
|
+
const copy = useVariantValue("cta-copy");
|
|
150
|
+
// With codegen: "Buy now" | "Get started" | "Try it free"
|
|
151
|
+
// Without codegen: string
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
### Component
|
|
155
|
+
|
|
156
|
+
```tsx
|
|
157
|
+
import { VariantValue } from "@variantlab/react";
|
|
158
|
+
|
|
159
|
+
<VariantValue experimentId="cta-copy">
|
|
160
|
+
{(copy) => <Button>{copy}</Button>}
|
|
161
|
+
</VariantValue>
|
|
162
|
+
```
|
|
163
|
+
|
|
164
|
+
Useful for avoiding hook rules in conditional contexts.
|
|
165
|
+
|
|
166
|
+
### Server-side
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
import { getVariantValueSSR } from "@variantlab/next";
|
|
170
|
+
|
|
171
|
+
const copy = await getVariantValueSSR("cta-copy", context);
|
|
172
|
+
```
|
|
173
|
+
|
|
174
|
+
---
|
|
175
|
+
|
|
176
|
+
## Type inference
|
|
177
|
+
|
|
178
|
+
With codegen active, the return type is inferred from the config:
|
|
179
|
+
|
|
180
|
+
```json
|
|
181
|
+
{
|
|
182
|
+
"id": "theme",
|
|
183
|
+
"type": "value",
|
|
184
|
+
"default": "light",
|
|
185
|
+
"variants": [
|
|
186
|
+
{ "id": "light", "value": "light" },
|
|
187
|
+
{ "id": "dark", "value": "dark" },
|
|
188
|
+
{ "id": "auto", "value": "auto" }
|
|
189
|
+
]
|
|
190
|
+
}
|
|
191
|
+
```
|
|
192
|
+
|
|
193
|
+
```ts
|
|
194
|
+
const theme = useVariantValue("theme");
|
|
195
|
+
// theme: "light" | "dark" | "auto"
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
For JSON objects, the type is a union of the object shapes:
|
|
199
|
+
|
|
200
|
+
```json
|
|
201
|
+
{
|
|
202
|
+
"id": "pricing",
|
|
203
|
+
"type": "value",
|
|
204
|
+
"variants": [
|
|
205
|
+
{ "id": "a", "value": { "price": 9.99, "label": "Basic" } },
|
|
206
|
+
{ "id": "b", "value": { "price": 14.99, "label": "Pro" } }
|
|
207
|
+
]
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
```ts
|
|
212
|
+
const pricing = useVariantValue("pricing");
|
|
213
|
+
// pricing: { price: 9.99; label: "Basic" } | { price: 14.99; label: "Pro" }
|
|
214
|
+
// or more usefully: { price: number; label: string }
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
The codegen emits a union by default; users can widen it manually.
|
|
218
|
+
|
|
219
|
+
### Without codegen
|
|
220
|
+
|
|
221
|
+
```ts
|
|
222
|
+
const theme = useVariantValue<"light" | "dark" | "auto">("theme");
|
|
223
|
+
```
|
|
224
|
+
|
|
225
|
+
---
|
|
226
|
+
|
|
227
|
+
## Supported value types
|
|
228
|
+
|
|
229
|
+
All JSON-serializable types:
|
|
230
|
+
|
|
231
|
+
- **string**
|
|
232
|
+
- **number**
|
|
233
|
+
- **boolean**
|
|
234
|
+
- **null**
|
|
235
|
+
- **array** of the above
|
|
236
|
+
- **object** of the above (nested)
|
|
237
|
+
|
|
238
|
+
### Not supported
|
|
239
|
+
|
|
240
|
+
- **Functions** — configs are JSON
|
|
241
|
+
- **Dates** — pass ISO strings instead
|
|
242
|
+
- **Regex** — pass string patterns, compile in the app
|
|
243
|
+
- **undefined** — use `null` or omit the value
|
|
244
|
+
- **Symbols**, **BigInt**, **Maps**, **Sets** — not JSON-compatible
|
|
245
|
+
|
|
246
|
+
If you need a non-serializable value, read a serializable key and map it in the app:
|
|
247
|
+
|
|
248
|
+
```ts
|
|
249
|
+
const themeName = useVariantValue("theme");
|
|
250
|
+
const themeColors = themeMap[themeName];
|
|
251
|
+
```
|
|
252
|
+
|
|
253
|
+
---
|
|
254
|
+
|
|
255
|
+
## Feature flag patterns
|
|
256
|
+
|
|
257
|
+
variantlab as a feature flag system:
|
|
258
|
+
|
|
259
|
+
### Boolean flag
|
|
260
|
+
|
|
261
|
+
```json
|
|
262
|
+
{
|
|
263
|
+
"id": "ai-tab-enabled",
|
|
264
|
+
"type": "value",
|
|
265
|
+
"default": "off",
|
|
266
|
+
"variants": [
|
|
267
|
+
{ "id": "off", "value": false },
|
|
268
|
+
{ "id": "on", "value": true }
|
|
269
|
+
]
|
|
270
|
+
}
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
```tsx
|
|
274
|
+
const enabled = useVariantValue("ai-tab-enabled");
|
|
275
|
+
return enabled ? <AITab /> : null;
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
### Staged rollout
|
|
279
|
+
|
|
280
|
+
```json
|
|
281
|
+
{
|
|
282
|
+
"id": "new-checkout",
|
|
283
|
+
"type": "value",
|
|
284
|
+
"assignment": "weighted",
|
|
285
|
+
"split": { "off": 90, "on": 10 },
|
|
286
|
+
"default": "off",
|
|
287
|
+
"variants": [
|
|
288
|
+
{ "id": "off", "value": false },
|
|
289
|
+
{ "id": "on", "value": true }
|
|
290
|
+
]
|
|
291
|
+
}
|
|
292
|
+
```
|
|
293
|
+
|
|
294
|
+
Start at 10%, increase the `split` over time:
|
|
295
|
+
|
|
296
|
+
```json
|
|
297
|
+
"split": { "off": 50, "on": 50 }
|
|
298
|
+
```
|
|
299
|
+
|
|
300
|
+
```json
|
|
301
|
+
"split": { "off": 0, "on": 100 }
|
|
302
|
+
```
|
|
303
|
+
|
|
304
|
+
### Kill switch
|
|
305
|
+
|
|
306
|
+
Set `enabled: false` at the top level to instantly disable all experiments:
|
|
307
|
+
|
|
308
|
+
```json
|
|
309
|
+
{
|
|
310
|
+
"version": 1,
|
|
311
|
+
"enabled": false,
|
|
312
|
+
"experiments": [...]
|
|
313
|
+
}
|
|
314
|
+
```
|
|
315
|
+
|
|
316
|
+
Or archive a specific one:
|
|
317
|
+
|
|
318
|
+
```json
|
|
319
|
+
{ "id": "new-checkout", "status": "archived", ... }
|
|
320
|
+
```
|
|
321
|
+
|
|
322
|
+
Archived experiments always return their default.
|
|
323
|
+
|
|
324
|
+
### Targeted beta
|
|
325
|
+
|
|
326
|
+
```json
|
|
327
|
+
{
|
|
328
|
+
"id": "ai-assistant-beta",
|
|
329
|
+
"type": "value",
|
|
330
|
+
"default": "off",
|
|
331
|
+
"targeting": { "attributes": { "betaOptIn": true } },
|
|
332
|
+
"variants": [
|
|
333
|
+
{ "id": "off", "value": false },
|
|
334
|
+
{ "id": "on", "value": true }
|
|
335
|
+
]
|
|
336
|
+
}
|
|
337
|
+
```
|
|
338
|
+
|
|
339
|
+
Only users with `betaOptIn: true` see the feature.
|
|
340
|
+
|
|
341
|
+
---
|
|
342
|
+
|
|
343
|
+
## Remote config patterns
|
|
344
|
+
|
|
345
|
+
variantlab as a remote-config tool:
|
|
346
|
+
|
|
347
|
+
### Dynamic text
|
|
348
|
+
|
|
349
|
+
```json
|
|
350
|
+
{
|
|
351
|
+
"id": "welcome-banner-text",
|
|
352
|
+
"type": "value",
|
|
353
|
+
"default": "default",
|
|
354
|
+
"variants": [
|
|
355
|
+
{ "id": "default", "value": "Welcome to Drishtikon" },
|
|
356
|
+
{ "id": "holiday", "value": "Happy New Year!" }
|
|
357
|
+
]
|
|
358
|
+
}
|
|
359
|
+
```
|
|
360
|
+
|
|
361
|
+
### Dynamic URLs
|
|
362
|
+
|
|
363
|
+
```json
|
|
364
|
+
{
|
|
365
|
+
"id": "api-endpoint",
|
|
366
|
+
"type": "value",
|
|
367
|
+
"default": "prod",
|
|
368
|
+
"variants": [
|
|
369
|
+
{ "id": "prod", "value": "https://api.example.com" },
|
|
370
|
+
{ "id": "staging", "value": "https://staging.example.com" }
|
|
371
|
+
]
|
|
372
|
+
}
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
### Dynamic pricing
|
|
376
|
+
|
|
377
|
+
```json
|
|
378
|
+
{
|
|
379
|
+
"id": "premium-price",
|
|
380
|
+
"type": "value",
|
|
381
|
+
"assignment": "weighted",
|
|
382
|
+
"split": { "low": 25, "mid": 50, "high": 25 },
|
|
383
|
+
"default": "mid",
|
|
384
|
+
"variants": [
|
|
385
|
+
{ "id": "low", "value": 4.99 },
|
|
386
|
+
{ "id": "mid", "value": 9.99 },
|
|
387
|
+
{ "id": "high", "value": 14.99 }
|
|
388
|
+
]
|
|
389
|
+
}
|
|
390
|
+
```
|
|
391
|
+
|
|
392
|
+
### Time-boxed promotions
|
|
393
|
+
|
|
394
|
+
```json
|
|
395
|
+
{
|
|
396
|
+
"id": "black-friday-discount",
|
|
397
|
+
"type": "value",
|
|
398
|
+
"startDate": "2026-11-24T00:00:00Z",
|
|
399
|
+
"endDate": "2026-12-01T00:00:00Z",
|
|
400
|
+
"default": "off",
|
|
401
|
+
"variants": [
|
|
402
|
+
{ "id": "off", "value": 0 },
|
|
403
|
+
{ "id": "on", "value": 0.25 }
|
|
404
|
+
]
|
|
405
|
+
}
|
|
406
|
+
```
|
|
407
|
+
|
|
408
|
+
---
|
|
409
|
+
|
|
410
|
+
## Gotchas
|
|
411
|
+
|
|
412
|
+
### Value experiments don't render exhaustive
|
|
413
|
+
|
|
414
|
+
If you use `useVariantValue` + a switch statement, you can miss cases. The type system will not catch it unless you explicitly narrow:
|
|
415
|
+
|
|
416
|
+
```ts
|
|
417
|
+
const theme = useVariantValue("theme");
|
|
418
|
+
if (theme === "light") return <Light />;
|
|
419
|
+
if (theme === "dark") return <Dark />;
|
|
420
|
+
// forgot "auto" — no compile error unless you use exhaustive checks
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
For exhaustiveness, use `<Variant>` with `type: "render"` instead:
|
|
424
|
+
|
|
425
|
+
```tsx
|
|
426
|
+
<Variant experimentId="theme">
|
|
427
|
+
{{
|
|
428
|
+
light: <Light />,
|
|
429
|
+
dark: <Dark />,
|
|
430
|
+
auto: <Auto />, // ❌ error if missing
|
|
431
|
+
}}
|
|
432
|
+
</Variant>
|
|
433
|
+
```
|
|
434
|
+
|
|
435
|
+
### Mixing types across variants
|
|
436
|
+
|
|
437
|
+
The runtime doesn't enforce that all variants have the same value shape. You can do this:
|
|
438
|
+
|
|
439
|
+
```json
|
|
440
|
+
"variants": [
|
|
441
|
+
{ "id": "a", "value": "string" },
|
|
442
|
+
{ "id": "b", "value": 42 },
|
|
443
|
+
{ "id": "c", "value": true }
|
|
444
|
+
]
|
|
445
|
+
```
|
|
446
|
+
|
|
447
|
+
But it's a bad idea — your consumer code will have to handle `string | number | boolean` everywhere. Codegen will flag this with a linter warning.
|
|
448
|
+
|
|
449
|
+
### Default value must be in the variants list
|
|
450
|
+
|
|
451
|
+
```json
|
|
452
|
+
"default": "premium", // ❌ error if "premium" is not a variant ID
|
|
453
|
+
"variants": [
|
|
454
|
+
{ "id": "basic", "value": "..." },
|
|
455
|
+
{ "id": "pro", "value": "..." }
|
|
456
|
+
]
|
|
457
|
+
```
|
|
458
|
+
|
|
459
|
+
The validator catches this at config load time.
|
|
460
|
+
|
|
461
|
+
### Don't store sensitive data
|
|
462
|
+
|
|
463
|
+
Variant values are visible in the debug overlay, in network requests (if served remotely), and in the app bundle (if embedded). Don't put API keys, tokens, or secrets in variant values. Use environment variables for those.
|
|
464
|
+
|
|
465
|
+
---
|
|
466
|
+
|
|
467
|
+
## Comparison with other tools
|
|
468
|
+
|
|
469
|
+
| Tool | Feature flags | Multi-value | JSON values | Codegen |
|
|
470
|
+
|---|:-:|:-:|:-:|:-:|
|
|
471
|
+
| variantlab | ✅ | ✅ | ✅ | ✅ |
|
|
472
|
+
| Firebase Remote Config | ✅ | ⚠️ | ⚠️ | ❌ |
|
|
473
|
+
| LaunchDarkly | ✅ | ✅ | ✅ | ⚠️ enterprise |
|
|
474
|
+
| Statsig | ✅ | ✅ | ✅ | ⚠️ |
|
|
475
|
+
| GrowthBook | ✅ | ✅ | ✅ | ❌ |
|
|
476
|
+
| Unleash | ✅ | ❌ | ❌ | ❌ |
|
|
477
|
+
| ConfigCat | ✅ | ⚠️ | ⚠️ | ❌ |
|
|
478
|
+
|
|
479
|
+
variantlab is the only tool that combines full value-experiment support with local codegen and zero network dependency.
|
|
480
|
+
|
|
481
|
+
---
|
|
482
|
+
|
|
483
|
+
## See also
|
|
484
|
+
|
|
485
|
+
- [`killer-features.md`](./killer-features.md#4-value-and-render-experiments) — the differentiator
|
|
486
|
+
- [`multivariate.md`](./multivariate.md) — 3+ variant experiments
|
|
487
|
+
- [`codegen.md`](./codegen.md) — type-safe values
|