@variantlab/react-native 0.1.2 → 0.1.3

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.
Files changed (2) hide show
  1. package/README.md +390 -48
  2. package/package.json +3 -3
package/README.md CHANGED
@@ -23,96 +23,405 @@ npm install @variantlab/core@alpha @variantlab/react@alpha @variantlab/react-nat
23
23
  - `react-native-safe-area-context` — safe area for debug overlay
24
24
  - `react-native-svg` — QR code rendering
25
25
 
26
- ## Quick start
26
+ ---
27
+
28
+ ## Complete example
29
+
30
+ Here's a full working setup — from config to rendering variants:
31
+
32
+ ### `experiments.json`
33
+
34
+ ```json
35
+ {
36
+ "version": 1,
37
+ "experiments": [
38
+ {
39
+ "id": "card-layout",
40
+ "name": "Card layout experiment",
41
+ "type": "render",
42
+ "default": "standard",
43
+ "variants": [
44
+ { "id": "standard" },
45
+ { "id": "compact" },
46
+ { "id": "pip-thumbnail" }
47
+ ]
48
+ },
49
+ {
50
+ "id": "cta-copy",
51
+ "name": "CTA button text",
52
+ "type": "value",
53
+ "default": "buy-now",
54
+ "variants": [
55
+ { "id": "buy-now", "value": "Buy now" },
56
+ { "id": "get-started", "value": "Get started" },
57
+ { "id": "try-free", "value": "Try it free" }
58
+ ]
59
+ },
60
+ {
61
+ "id": "onboarding-flow",
62
+ "name": "Onboarding flow",
63
+ "type": "render",
64
+ "default": "classic",
65
+ "assignment": { "strategy": "sticky-hash" },
66
+ "variants": [
67
+ { "id": "classic" },
68
+ { "id": "quick-start" }
69
+ ]
70
+ }
71
+ ]
72
+ }
73
+ ```
27
74
 
28
- ### 1. Create the engine with auto-context
75
+ ### `variantlab.ts` engine setup
29
76
 
30
- ```tsx
77
+ ```ts
31
78
  import { createEngine } from "@variantlab/core";
32
79
  import { getAutoContext, createAsyncStorageAdapter } from "@variantlab/react-native";
33
80
  import AsyncStorage from "@react-native-async-storage/async-storage";
34
81
  import experiments from "./experiments.json";
35
82
 
36
- const engine = createEngine(experiments, {
37
- context: getAutoContext(), // auto-detects platform, screenSize, locale
83
+ export const engine = createEngine(experiments, {
84
+ context: {
85
+ ...getAutoContext(), // auto-detects platform, screenSize, locale
86
+ userId: "user-123", // your authenticated user ID
87
+ },
38
88
  storage: createAsyncStorageAdapter(AsyncStorage),
39
89
  });
40
90
  ```
41
91
 
42
- ### 2. Wrap your app
92
+ ### `app/_layout.tsx` wrap your app
43
93
 
44
94
  ```tsx
45
95
  import { VariantLabProvider } from "@variantlab/react-native";
96
+ import { VariantDebugOverlay } from "@variantlab/react-native/debug";
97
+ import { engine } from "./variantlab";
46
98
 
47
- export default function App() {
99
+ export default function RootLayout() {
48
100
  return (
49
101
  <VariantLabProvider engine={engine}>
50
- <YourApp />
102
+ <Slot />
103
+ {__DEV__ && <VariantDebugOverlay />}
51
104
  </VariantLabProvider>
52
105
  );
53
106
  }
54
107
  ```
55
108
 
56
- ### 3. Use hooks (same API as @variantlab/react)
109
+ ---
110
+
111
+ ## Hooks
112
+
113
+ ### `useVariant(experimentId)` — get the active variant ID
114
+
115
+ Use this for **render experiments** where you switch between different components.
116
+
117
+ ```tsx
118
+ import { View } from "react-native";
119
+ import { useVariant } from "@variantlab/react-native";
120
+
121
+ function CardSection() {
122
+ const layout = useVariant("card-layout");
123
+ // Returns: "standard" | "compact" | "pip-thumbnail"
124
+
125
+ switch (layout) {
126
+ case "compact":
127
+ return <CompactCard />;
128
+ case "pip-thumbnail":
129
+ return <PipThumbnailCard />;
130
+ default:
131
+ return <StandardCard />;
132
+ }
133
+ }
134
+ ```
135
+
136
+ ### `useVariantValue<T>(experimentId)` — get the experiment value
137
+
138
+ Use this for **value experiments** where variants carry data (strings, numbers, objects).
57
139
 
58
140
  ```tsx
59
- import { useVariant, useVariantValue, Variant } from "@variantlab/react-native";
141
+ import { Text, TouchableOpacity } from "react-native";
142
+ import { useVariantValue } from "@variantlab/react-native";
143
+
144
+ function CheckoutButton() {
145
+ const buttonText = useVariantValue<string>("cta-copy");
146
+ // Returns: "Buy now" | "Get started" | "Try it free"
147
+
148
+ return (
149
+ <TouchableOpacity style={styles.button}>
150
+ <Text>{buttonText}</Text>
151
+ </TouchableOpacity>
152
+ );
153
+ }
154
+
155
+ function PricingDisplay() {
156
+ const price = useVariantValue<number>("pricing-tier");
157
+ // Returns: 9.99 | 14.99 | 19.99
158
+
159
+ return <Text>${price}/month</Text>;
160
+ }
161
+ ```
162
+
163
+ ### `useExperiment(experimentId)` — get full experiment state
164
+
165
+ Returns the variant ID, the experiment config, and whether it's been manually overridden.
166
+
167
+ ```tsx
168
+ import { Text, View } from "react-native";
169
+ import { useExperiment } from "@variantlab/react-native";
170
+
171
+ function DebugBanner() {
172
+ const { variantId, experiment, isOverridden } = useExperiment("card-layout");
173
+
174
+ return (
175
+ <View>
176
+ <Text>Experiment: {experiment.name}</Text>
177
+ <Text>Active variant: {variantId}</Text>
178
+ {isOverridden && <Text style={{ color: "orange" }}>⚠ Manually overridden</Text>}
179
+ </View>
180
+ );
181
+ }
182
+ ```
183
+
184
+ ### `useSetVariant()` — override a variant (for testing/QA)
185
+
186
+ Returns a function to force-assign a variant. Useful for building your own debug UI or testing different variants during development.
187
+
188
+ ```tsx
189
+ import { Button, View } from "react-native";
190
+ import { useSetVariant, useVariant } from "@variantlab/react-native";
191
+
192
+ function VariantPicker() {
193
+ const setVariant = useSetVariant();
194
+ const current = useVariant("card-layout");
195
+
196
+ return (
197
+ <View>
198
+ <Text>Current: {current}</Text>
199
+ <Button title="Standard" onPress={() => setVariant("card-layout", "standard")} />
200
+ <Button title="Compact" onPress={() => setVariant("card-layout", "compact")} />
201
+ <Button title="PiP" onPress={() => setVariant("card-layout", "pip-thumbnail")} />
202
+ </View>
203
+ );
204
+ }
205
+ ```
206
+
207
+ ### `useVariantLabEngine()` — access the engine directly
208
+
209
+ Returns the engine instance for advanced operations like resetting all overrides or updating context.
210
+
211
+ ```tsx
212
+ import { Button } from "react-native";
213
+ import { useVariantLabEngine } from "@variantlab/react-native";
214
+
215
+ function ResetButton() {
216
+ const engine = useVariantLabEngine();
217
+
218
+ return (
219
+ <Button
220
+ title="Reset all experiments"
221
+ onPress={() => engine.resetAll()}
222
+ />
223
+ );
224
+ }
225
+
226
+ function ContextUpdater() {
227
+ const engine = useVariantLabEngine();
228
+
229
+ const onLogin = (userId: string) => {
230
+ engine.updateContext({ userId });
231
+ };
60
232
 
61
- function CardLayout() {
62
- const variant = useVariant("card-layout");
63
233
  // ...
64
234
  }
235
+ ```
236
+
237
+ ### `useRouteExperiments()` — get experiments targeting the current route
238
+
239
+ Returns only the experiments whose targeting rules match the current route (useful with Expo Router).
240
+
241
+ ```tsx
242
+ import { Text, FlatList } from "react-native";
243
+ import { useRouteExperiments } from "@variantlab/react-native";
244
+
245
+ function RouteExperimentsList() {
246
+ const experiments = useRouteExperiments();
247
+
248
+ return (
249
+ <FlatList
250
+ data={experiments}
251
+ renderItem={({ item }) => (
252
+ <Text>{item.name}: {item.variantId}</Text>
253
+ )}
254
+ />
255
+ );
256
+ }
257
+ ```
258
+
259
+ ---
260
+
261
+ ## Components
262
+
263
+ ### `<Variant>` — render-swap by variant ID
264
+
265
+ Renders the child matching the active variant. Cleaner than a switch statement when you have distinct JSX per variant.
266
+
267
+ ```tsx
268
+ import { Variant } from "@variantlab/react-native";
269
+
270
+ function OnboardingScreen() {
271
+ return (
272
+ <Variant experimentId="onboarding-flow" fallback={<ClassicOnboarding />}>
273
+ {{
274
+ classic: <ClassicOnboarding />,
275
+ "quick-start": <QuickStartOnboarding />,
276
+ }}
277
+ </Variant>
278
+ );
279
+ }
280
+ ```
281
+
282
+ ### `<VariantValue>` — render-prop for value experiments
283
+
284
+ Passes the experiment value to a render function.
285
+
286
+ ```tsx
287
+ import { Text } from "react-native";
288
+ import { VariantValue } from "@variantlab/react-native";
289
+
290
+ function WelcomeBanner() {
291
+ return (
292
+ <VariantValue experimentId="welcome-message">
293
+ {(message) => <Text style={styles.banner}>{message}</Text>}
294
+ </VariantValue>
295
+ );
296
+ }
297
+ ```
298
+
299
+ ### `<VariantErrorBoundary>` — crash-safe experiments
300
+
301
+ Wraps an experiment in an error boundary. If a variant crashes repeatedly, the engine auto-rolls back to the default variant and renders the fallback.
302
+
303
+ ```tsx
304
+ import { Text } from "react-native";
305
+ import { VariantErrorBoundary } from "@variantlab/react-native";
65
306
 
66
- function PriceDisplay() {
67
- const price = useVariantValue<number>("pricing");
68
- return <Text>${price}</Text>;
307
+ function SafeCardSection() {
308
+ return (
309
+ <VariantErrorBoundary
310
+ experimentId="card-layout"
311
+ fallback={<Text>Something went wrong. Showing default layout.</Text>}
312
+ >
313
+ <CardSection />
314
+ </VariantErrorBoundary>
315
+ );
69
316
  }
70
317
  ```
71
318
 
319
+ ### `<VariantLabProvider>` — context provider
320
+
321
+ Wraps your app and provides the engine to all hooks and components. Must be at the top of your component tree.
322
+
323
+ ```tsx
324
+ import { VariantLabProvider } from "@variantlab/react-native";
325
+ import { engine } from "./variantlab";
326
+
327
+ export default function App() {
328
+ return (
329
+ <VariantLabProvider engine={engine}>
330
+ {/* All hooks and components work inside here */}
331
+ <Navigation />
332
+ </VariantLabProvider>
333
+ );
334
+ }
335
+ ```
336
+
337
+ ---
338
+
72
339
  ## Auto-context detection
73
340
 
74
- `getAutoContext()` automatically detects:
341
+ `getAutoContext()` reads device info automatically so your targeting rules just work:
75
342
 
76
- | Field | Source |
77
- |-------|--------|
78
- | `platform` | `Platform.OS` (`ios`, `android`, `web`) |
79
- | `screenSize` | `Dimensions.get("window").width` bucketed to `small` / `medium` / `large` |
80
- | `locale` | `expo-localization` or `NativeModules` |
81
- | `appVersion` | `expo-constants` or `DeviceInfo` |
343
+ | Field | Source | Example |
344
+ |-------|--------|---------|
345
+ | `platform` | `Platform.OS` | `"ios"`, `"android"`, `"web"` |
346
+ | `screenSize` | `Dimensions.get("window").width` | `"small"` (<375), `"medium"` (375-767), `"large"` (768+) |
347
+ | `locale` | `expo-localization` or `NativeModules` | `"en"`, `"bn"`, `"fr"` |
348
+ | `appVersion` | `expo-constants` or `DeviceInfo` | `"2.1.0"` |
82
349
 
83
350
  ```tsx
84
351
  import { getAutoContext } from "@variantlab/react-native";
85
352
 
86
353
  const context = getAutoContext();
87
- // { platform: "ios", screenSize: "medium", locale: "en", ... }
354
+ // { platform: "ios", screenSize: "medium", locale: "en", appVersion: "2.1.0" }
88
355
  ```
89
356
 
357
+ You can merge it with your own context:
358
+
359
+ ```ts
360
+ const engine = createEngine(experiments, {
361
+ context: {
362
+ ...getAutoContext(),
363
+ userId: "user-123",
364
+ attributes: { plan: "pro", country: "BD" },
365
+ },
366
+ });
367
+ ```
368
+
369
+ ---
370
+
90
371
  ## Storage adapters
91
372
 
92
- Choose the storage backend that fits your app:
373
+ Persist variant assignments across app restarts. Pick the one that fits your stack:
374
+
375
+ ### AsyncStorage (most common)
93
376
 
94
377
  ```tsx
378
+ import AsyncStorage from "@react-native-async-storage/async-storage";
95
379
  import { createAsyncStorageAdapter } from "@variantlab/react-native";
96
- import { createMMKVStorageAdapter } from "@variantlab/react-native";
97
- import { createSecureStoreAdapter } from "@variantlab/react-native";
98
- import { createMemoryStorage } from "@variantlab/react-native";
99
380
 
100
- // AsyncStorage — most common, works everywhere
101
381
  const storage = createAsyncStorageAdapter(AsyncStorage);
382
+ ```
383
+
384
+ ### MMKV (fastest — synchronous reads)
102
385
 
103
- // MMKV — faster, synchronous reads
386
+ ```tsx
387
+ import { MMKV } from "react-native-mmkv";
388
+ import { createMMKVStorageAdapter } from "@variantlab/react-native";
389
+
390
+ const mmkv = new MMKV();
104
391
  const storage = createMMKVStorageAdapter(mmkv);
392
+ ```
393
+
394
+ ### SecureStore (encrypted — for sensitive data)
395
+
396
+ ```tsx
397
+ import * as SecureStore from "expo-secure-store";
398
+ import { createSecureStoreAdapter } from "@variantlab/react-native";
105
399
 
106
- // SecureStore — encrypted, for sensitive experiment data
107
400
  const storage = createSecureStoreAdapter(SecureStore);
401
+ ```
402
+
403
+ ### Memory (no persistence — for tests)
404
+
405
+ ```tsx
406
+ import { createMemoryStorage } from "@variantlab/react-native";
108
407
 
109
- // Memory — no persistence, resets on restart (good for tests)
110
408
  const storage = createMemoryStorage();
111
409
  ```
112
410
 
411
+ Pass the storage to your engine:
412
+
413
+ ```ts
414
+ const engine = createEngine(experiments, {
415
+ context: getAutoContext(),
416
+ storage, // variant assignments persist here
417
+ });
418
+ ```
419
+
420
+ ---
421
+
113
422
  ## Debug overlay
114
423
 
115
- A built-in bottom-sheet UI for viewing and overriding experiments on device.
424
+ A floating button that opens a bottom-sheet for viewing and overriding experiments on device. Only use in development.
116
425
 
117
426
  ```tsx
118
427
  import { VariantDebugOverlay } from "@variantlab/react-native/debug";
@@ -127,48 +436,81 @@ export default function App() {
127
436
  }
128
437
  ```
129
438
 
130
- The overlay shows:
131
- - All active experiments and current assignments
132
- - Tap to override any variant
133
- - Current targeting context
134
- - Assignment source (default, hash, override, etc.)
439
+ What the overlay shows:
440
+ - All active experiments with their current variant
441
+ - Tap any experiment to switch variants
442
+ - Current targeting context (platform, screenSize, locale, etc.)
443
+ - Assignment source for each experiment (default, sticky-hash, override, etc.)
444
+ - Search/filter experiments
445
+
446
+ Customize the trigger position:
447
+
448
+ ```tsx
449
+ <VariantDebugOverlay corner="bottom-left" />
450
+ ```
451
+
452
+ ---
135
453
 
136
454
  ## Deep link overrides
137
455
 
138
- Allow QA to force variants via deep links:
456
+ Let your QA team force variants by opening a URL:
139
457
 
140
458
  ```
141
- myapp://variantlab?set=hero-layout:split
459
+ myapp://variantlab?set=card-layout:compact
142
460
  ```
143
461
 
462
+ ### Setup
463
+
144
464
  ```tsx
145
465
  import { registerDeepLinkHandler } from "@variantlab/react-native";
466
+ import { engine } from "./variantlab";
146
467
 
147
- // In your app setup
468
+ // Call once during app initialization
148
469
  registerDeepLinkHandler(engine);
149
470
  ```
150
471
 
472
+ Now opening `myapp://variantlab?set=card-layout:compact` will force the `card-layout` experiment to the `compact` variant.
473
+
474
+ ---
475
+
151
476
  ## QR sharing
152
477
 
153
- Share experiment state with teammates via QR codes:
478
+ Share your current experiment state with teammates they scan the QR and get the exact same variants.
154
479
 
155
480
  ```tsx
156
481
  import { buildQrUrl, parseQrUrl } from "@variantlab/react-native/qr";
482
+ import { encodeSharePayload, decodeSharePayload } from "@variantlab/react-native";
157
483
 
158
- // Encode current state into a URL
484
+ // Build a shareable URL from current assignments
485
+ const payload = encodeSharePayload({
486
+ v: 1,
487
+ u: "user-123",
488
+ a: { "card-layout": "compact", "cta-copy": "try-free" },
489
+ });
159
490
  const url = buildQrUrl(payload);
491
+ // "variantlab://apply?p=..."
160
492
 
161
- // Parse a scanned QR URL
162
- const result = parseQrUrl(url);
493
+ // Parse a received QR URL
494
+ const result = parseQrUrl(scannedUrl);
495
+ if (result.ok) {
496
+ // Apply the assignments to the engine
497
+ applyPayload(engine, result.payload);
498
+ }
163
499
  ```
164
500
 
165
- ## All re-exported hooks and components
501
+ ---
166
502
 
167
- This package re-exports everything from `@variantlab/react`:
503
+ ## Codegen (type safety)
504
+
505
+ Generate TypeScript types so experiment IDs and variant IDs are checked at compile time:
506
+
507
+ ```bash
508
+ npx @variantlab/cli@alpha generate
509
+ ```
168
510
 
169
- **Hooks:** `useVariant`, `useVariantValue`, `useExperiment`, `useSetVariant`, `useVariantLabEngine`, `useRouteExperiments`
511
+ After codegen, `useVariant("card-layout")` returns `"standard" | "compact" | "pip-thumbnail"` as a literal type. Typos become compile errors.
170
512
 
171
- **Components:** `<Variant>`, `<VariantValue>`, `<VariantErrorBoundary>`, `<VariantLabProvider>`
513
+ ---
172
514
 
173
515
  ## License
174
516
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@variantlab/react-native",
3
- "version": "0.1.2",
3
+ "version": "0.1.3",
4
4
  "description": "React Native and Expo bindings for variantlab.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -71,8 +71,8 @@
71
71
  "node": ">=18.17"
72
72
  },
73
73
  "dependencies": {
74
- "@variantlab/core": "0.1.2",
75
- "@variantlab/react": "0.1.2"
74
+ "@variantlab/core": "0.1.3",
75
+ "@variantlab/react": "0.1.3"
76
76
  },
77
77
  "peerDependencies": {
78
78
  "@react-native-async-storage/async-storage": "*",