@variantlab/react 0.1.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 variantlab contributors
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.
package/README.md ADDED
@@ -0,0 +1,11 @@
1
+ # @variantlab/react
2
+
3
+ React hooks and components for variantlab.
4
+
5
+ > **Status:** Phase 1 — Pre-alpha. Not ready for production use.
6
+
7
+ ## Peer dependencies
8
+
9
+ - `react` `^18.2.0 || ^19.0.0`
10
+
11
+ See the [root README](../../README.md) for project overview, motivation, and roadmap.
package/dist/index.cjs ADDED
@@ -0,0 +1,180 @@
1
+ 'use strict';
2
+
3
+ var react = require('react');
4
+ var jsxRuntime = require('react/jsx-runtime');
5
+
6
+ // src/components/error-boundary.tsx
7
+ var VariantLabContext = react.createContext(null);
8
+ function VariantLabProvider({
9
+ engine,
10
+ initialContext,
11
+ children
12
+ }) {
13
+ const value = react.useMemo(() => engine, [engine]);
14
+ const appliedRef = react.useRef(null);
15
+ react.useLayoutEffect(() => {
16
+ if (initialContext === void 0) return;
17
+ const prev = appliedRef.current;
18
+ if (prev !== null && prev.engine === engine && prev.ctx === initialContext) {
19
+ return;
20
+ }
21
+ engine.updateContext(initialContext);
22
+ appliedRef.current = { engine, ctx: initialContext };
23
+ }, [engine, initialContext]);
24
+ return /* @__PURE__ */ jsxRuntime.jsx(VariantLabContext.Provider, { value, children });
25
+ }
26
+
27
+ // src/components/error-boundary.tsx
28
+ var VariantErrorBoundary = class extends react.Component {
29
+ constructor() {
30
+ super(...arguments);
31
+ this.state = { error: null };
32
+ }
33
+ static getDerivedStateFromError(error) {
34
+ return { error };
35
+ }
36
+ componentDidCatch(error, _info) {
37
+ const engine = this.context;
38
+ if (engine !== null) {
39
+ try {
40
+ engine.reportCrash(this.props.experimentId, error);
41
+ } catch {
42
+ }
43
+ }
44
+ }
45
+ componentDidUpdate(prevProps) {
46
+ if (this.state.error !== null && prevProps.children !== this.props.children) {
47
+ this.setState({ error: null });
48
+ }
49
+ }
50
+ render() {
51
+ const { error } = this.state;
52
+ if (error !== null) {
53
+ const { fallback } = this.props;
54
+ if (typeof fallback === "function") return fallback(error);
55
+ return fallback ?? null;
56
+ }
57
+ return this.props.children;
58
+ }
59
+ };
60
+ VariantErrorBoundary.contextType = VariantLabContext;
61
+ function useVariantLabEngine() {
62
+ const engine = react.useContext(VariantLabContext);
63
+ if (engine === null) {
64
+ throw new Error("useVariantLabEngine: no <VariantLabProvider> found above this component.");
65
+ }
66
+ return engine;
67
+ }
68
+
69
+ // src/hooks/use-variant.ts
70
+ function useVariant(experimentId) {
71
+ const engine = useVariantLabEngine();
72
+ const subscribe = react.useCallback(
73
+ (onStoreChange) => {
74
+ return engine.subscribe((event) => {
75
+ if (shouldNotify(event, experimentId)) onStoreChange();
76
+ });
77
+ },
78
+ [engine, experimentId]
79
+ );
80
+ const getSnapshot = react.useCallback(
81
+ () => engine.getVariant(experimentId),
82
+ [engine, experimentId]
83
+ );
84
+ return react.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
85
+ }
86
+ function shouldNotify(event, experimentId) {
87
+ switch (event.type) {
88
+ case "configLoaded":
89
+ case "contextUpdated":
90
+ return true;
91
+ case "variantChanged":
92
+ case "rollback":
93
+ case "assignment":
94
+ return event.experimentId === experimentId;
95
+ default:
96
+ return false;
97
+ }
98
+ }
99
+
100
+ // src/components/variant.tsx
101
+ function Variant({ experimentId, children, fallback }) {
102
+ const active = useVariant(experimentId);
103
+ const node = children[active];
104
+ if (node !== void 0) return node;
105
+ return fallback ?? null;
106
+ }
107
+
108
+ // src/hooks/use-variant-value.ts
109
+ function useVariantValue(experimentId) {
110
+ const engine = useVariantLabEngine();
111
+ useVariant(experimentId);
112
+ return engine.getVariantValue(experimentId);
113
+ }
114
+
115
+ // src/components/variant-value.tsx
116
+ function VariantValue({
117
+ experimentId,
118
+ children
119
+ }) {
120
+ const value = useVariantValue(experimentId);
121
+ return children(value);
122
+ }
123
+ function useExperiment(experimentId) {
124
+ const engine = useVariantLabEngine();
125
+ const variant = useVariant(experimentId);
126
+ const value = engine.getVariantValue(experimentId);
127
+ const track = react.useCallback((_eventName, _properties) => {
128
+ }, []);
129
+ return { variant, value, track };
130
+ }
131
+ function useRouteExperiments(route) {
132
+ const engine = useVariantLabEngine();
133
+ const cacheRef = react.useRef(null);
134
+ const subscribe = react.useCallback(
135
+ (onStoreChange) => {
136
+ return engine.subscribe((event) => {
137
+ if (event.type === "configLoaded" || event.type === "contextUpdated") {
138
+ cacheRef.current = null;
139
+ onStoreChange();
140
+ }
141
+ });
142
+ },
143
+ [engine]
144
+ );
145
+ const getSnapshot = react.useCallback(() => {
146
+ const cached = cacheRef.current;
147
+ if (cached !== null && cached.route === route) return cached.snapshot;
148
+ const snapshot = engine.getExperiments(route);
149
+ cacheRef.current = { route, snapshot };
150
+ return snapshot;
151
+ }, [engine, route]);
152
+ return react.useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
153
+ }
154
+ function useSetVariant() {
155
+ const engine = useVariantLabEngine();
156
+ return react.useCallback(
157
+ (experimentId, variantId) => {
158
+ engine.setVariant(experimentId, variantId);
159
+ },
160
+ [engine]
161
+ );
162
+ }
163
+
164
+ // src/index.ts
165
+ var VERSION = "0.0.0";
166
+
167
+ exports.VERSION = VERSION;
168
+ exports.Variant = Variant;
169
+ exports.VariantErrorBoundary = VariantErrorBoundary;
170
+ exports.VariantLabContext = VariantLabContext;
171
+ exports.VariantLabProvider = VariantLabProvider;
172
+ exports.VariantValue = VariantValue;
173
+ exports.useExperiment = useExperiment;
174
+ exports.useRouteExperiments = useRouteExperiments;
175
+ exports.useSetVariant = useSetVariant;
176
+ exports.useVariant = useVariant;
177
+ exports.useVariantLabEngine = useVariantLabEngine;
178
+ exports.useVariantValue = useVariantValue;
179
+ //# sourceMappingURL=index.cjs.map
180
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/context.tsx","../src/components/error-boundary.tsx","../src/hooks/use-variant-lab-engine.ts","../src/hooks/use-variant.ts","../src/components/variant.tsx","../src/hooks/use-variant-value.ts","../src/components/variant-value.tsx","../src/hooks/use-experiment.ts","../src/hooks/use-route-experiments.ts","../src/hooks/use-set-variant.ts","../src/index.ts"],"names":["createContext","useMemo","useRef","useLayoutEffect","jsx","Component","useContext","useCallback","useSyncExternalStore"],"mappings":"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","file":"index.cjs","sourcesContent":["/**\n * React context and provider for variantlab.\n *\n * The provider is a thin wrapper that hands a pre-built `VariantEngine`\n * to descendants via React context. It intentionally does NOT own the\n * engine: callers construct it via `createEngine(...)` and pass it in.\n * That keeps the adapter testable (swap in a mock engine), SSR-safe\n * (server code can build an engine before render), and free of\n * lifecycle surprises — unmounting the provider never disposes the\n * engine because the provider didn't create it.\n *\n * `initialContext`, when supplied, is applied via `useLayoutEffect`\n * before the first browser paint. We use a ref to guard against\n * repeated application on re-renders so the engine cache is only\n * busted when the caller actually swaps engines.\n */\n\nimport type { VariantContext, VariantEngine } from \"@variantlab/core\";\nimport { createContext, type ReactNode, useLayoutEffect, useMemo, useRef } from \"react\";\n\n/** The raw context value. `null` until a provider wraps the tree. */\nexport const VariantLabContext = createContext<VariantEngine | null>(null);\n\nexport interface VariantLabProviderProps {\n /** A pre-constructed engine from `createEngine(...)`. */\n readonly engine: VariantEngine;\n /** Optional runtime context applied once on mount. */\n readonly initialContext?: VariantContext;\n readonly children?: ReactNode;\n}\n\n/**\n * Wraps descendants so the hooks in this package can locate the engine.\n *\n * The value handed to `Context.Provider` is memoized on `engine` alone\n * so that unrelated parent re-renders don't force a new reference\n * (which would cascade through every `useVariant` consumer).\n */\nexport function VariantLabProvider({\n engine,\n initialContext,\n children,\n}: VariantLabProviderProps): ReactNode {\n // Stable context value: only changes when the engine itself changes.\n const value = useMemo(() => engine, [engine]);\n\n // Track which (engine, initialContext) pair we've already applied so\n // re-renders don't re-invoke updateContext (which would clear the\n // engine cache and cause unnecessary reassignment events).\n const appliedRef = useRef<{\n readonly engine: VariantEngine;\n readonly ctx: VariantContext | undefined;\n } | null>(null);\n\n useLayoutEffect(() => {\n if (initialContext === undefined) return;\n const prev = appliedRef.current;\n if (prev !== null && prev.engine === engine && prev.ctx === initialContext) {\n return;\n }\n engine.updateContext(initialContext);\n appliedRef.current = { engine, ctx: initialContext };\n }, [engine, initialContext]);\n\n return <VariantLabContext.Provider value={value}>{children}</VariantLabContext.Provider>;\n}\n","/**\n * `<VariantErrorBoundary>` — crash insulation for experiment render trees.\n *\n * Wraps a variant's subtree in a class-based error boundary (the only\n * remaining legitimate use of class components in modern React). When\n * a child throws, it:\n *\n * 1. reports the crash to the engine via `engine.reportCrash(id, err)`,\n * which feeds the rollback counter defined in `config-format.md`;\n * 2. renders `fallback` (or `null`) for the rest of the current\n * commit cycle;\n * 3. resets itself so the next render can try again with the new\n * variant (likely the rolled-back default after N crashes).\n *\n * The engine lookup goes through `VariantLabContext` rather than a\n * prop so the ergonomics match the hook-first API. `contextType` on\n * the class makes the context value available as `this.context` in\n * `componentDidCatch`.\n */\n\nimport type { VariantEngine } from \"@variantlab/core\";\nimport { Component, type ErrorInfo, type ReactNode } from \"react\";\nimport { VariantLabContext } from \"../context.js\";\n\nexport interface VariantErrorBoundaryProps {\n readonly experimentId: string;\n readonly fallback?: ReactNode | ((error: Error) => ReactNode);\n readonly children: ReactNode;\n}\n\ninterface VariantErrorBoundaryState {\n readonly error: Error | null;\n}\n\nexport class VariantErrorBoundary extends Component<\n VariantErrorBoundaryProps,\n VariantErrorBoundaryState\n> {\n static override contextType = VariantLabContext;\n declare context: VariantEngine | null;\n override state: VariantErrorBoundaryState = { error: null };\n\n static getDerivedStateFromError(error: Error): VariantErrorBoundaryState {\n return { error };\n }\n\n override componentDidCatch(error: Error, _info: ErrorInfo): void {\n const engine = this.context;\n if (engine !== null) {\n try {\n engine.reportCrash(this.props.experimentId, error);\n } catch {\n // Swallow crash-reporting failures — we must not double-throw\n // inside an error boundary.\n }\n }\n }\n\n override componentDidUpdate(prevProps: VariantErrorBoundaryProps): void {\n // If the children prop changes after a recovery (e.g. the engine\n // rolled back and a parent re-rendered with a different subtree),\n // clear the error so we attempt to render the new tree.\n if (this.state.error !== null && prevProps.children !== this.props.children) {\n this.setState({ error: null });\n }\n }\n\n override render(): ReactNode {\n const { error } = this.state;\n if (error !== null) {\n const { fallback } = this.props;\n if (typeof fallback === \"function\") return fallback(error);\n return fallback ?? null;\n }\n return this.props.children;\n }\n}\n","/**\n * `useVariantLabEngine` — raw engine access escape hatch.\n *\n * Most consumers should reach for `useVariant` / `useVariantValue` /\n * `useExperiment` instead. This hook is for code that needs to call\n * engine methods directly (e.g. `setVariant` from a debug panel, or\n * `reportCrash` from a custom error boundary).\n *\n * Throws synchronously when called outside a `VariantLabProvider`\n * because any other behavior (returning `null`, lazy init) would hide\n * the real bug behind mysterious fallbacks elsewhere in the tree.\n */\n\nimport type { VariantEngine } from \"@variantlab/core\";\nimport { useContext } from \"react\";\nimport { VariantLabContext } from \"../context.js\";\n\nexport function useVariantLabEngine(): VariantEngine {\n const engine = useContext(VariantLabContext);\n if (engine === null) {\n throw new Error(\"useVariantLabEngine: no <VariantLabProvider> found above this component.\");\n }\n return engine;\n}\n","/**\n * `useVariant` — read the current variant id for an experiment.\n *\n * Subscribes to engine events via `useSyncExternalStore` so that the\n * component re-renders whenever the variant for this experiment\n * actually changes (manual override, rollback, config reload, context\n * update). Events that don't affect this experiment are filtered out\n * at the listener level — React's bail-out on identical snapshots\n * handles the rest.\n *\n * The snapshot callback is `engine.getVariant(id)` directly. The\n * engine memoizes the result in an internal cache, so repeated reads\n * in the same render pass are O(1) and side-effect free — which is\n * exactly what `useSyncExternalStore` requires.\n */\n\nimport type { EngineEvent } from \"@variantlab/core\";\nimport { useCallback, useSyncExternalStore } from \"react\";\nimport { useVariantLabEngine } from \"./use-variant-lab-engine.js\";\n\nexport function useVariant(experimentId: string): string {\n const engine = useVariantLabEngine();\n\n const subscribe = useCallback(\n (onStoreChange: () => void): (() => void) => {\n return engine.subscribe((event: EngineEvent) => {\n if (shouldNotify(event, experimentId)) onStoreChange();\n });\n },\n [engine, experimentId],\n );\n\n const getSnapshot = useCallback(\n (): string => engine.getVariant(experimentId),\n [engine, experimentId],\n );\n\n return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);\n}\n\n/**\n * Decide whether an engine event should retrigger snapshot sampling.\n * Broad events (`configLoaded`, `contextUpdated`) always fire; targeted\n * events fire only when they name this experiment.\n */\nfunction shouldNotify(event: EngineEvent, experimentId: string): boolean {\n switch (event.type) {\n case \"configLoaded\":\n case \"contextUpdated\":\n return true;\n case \"variantChanged\":\n case \"rollback\":\n case \"assignment\":\n return event.experimentId === experimentId;\n default:\n return false;\n }\n}\n","/**\n * `<Variant>` — render-prop switch for \"render\" experiments.\n *\n * Takes a plain `Record<variantId, ReactNode>` as its children. The\n * shape intentionally looks like a lookup table so grep, codegen, and\n * TypeScript exhaustiveness checks all work without any custom\n * helpers. If the active variant isn't in the table we render\n * `fallback` if provided, else `null` — silent render failures are\n * preferable to crashing the tree when a config is missing a case.\n *\n * This component is tiny on purpose: the real logic lives in\n * `useVariant`, and everything here is table lookup plus a fallback.\n */\nimport type { ReactNode } from \"react\";\nimport { useVariant } from \"../hooks/use-variant.js\";\n\nexport interface VariantProps {\n readonly experimentId: string;\n readonly children: Readonly<Record<string, ReactNode>>;\n readonly fallback?: ReactNode;\n}\n\nexport function Variant({ experimentId, children, fallback }: VariantProps): ReactNode {\n const active = useVariant(experimentId);\n const node = children[active];\n if (node !== undefined) return node;\n return fallback ?? null;\n}\n","/**\n * `useVariantValue` — read a \"value\" experiment's typed payload.\n *\n * Thin wrapper around `useVariant` that looks up the matching\n * `variant.value` from the engine. Used for copy, pricing, feature\n * toggles, and any other experiment where the thing that varies is\n * data, not a React component.\n *\n * The generic `T` exists purely for type inference ergonomics. When\n * codegen is active it's filled in automatically from\n * `GeneratedExperiments[id][\"value\"]`; otherwise callers pass an\n * explicit type parameter.\n */\nimport { useVariant } from \"./use-variant.js\";\nimport { useVariantLabEngine } from \"./use-variant-lab-engine.js\";\n\nexport function useVariantValue<T = unknown>(experimentId: string): T {\n const engine = useVariantLabEngine();\n // Track the variant id so we re-render when it changes. The actual\n // value is fetched via `getVariantValue` which walks the variant\n // table; this is O(n_variants) which is fine for typical configs.\n useVariant(experimentId);\n return engine.getVariantValue<T>(experimentId);\n}\n","/**\n * `<VariantValue>` — function-as-child helper for \"value\" experiments.\n *\n * Lets callers declaratively render with a typed variant value without\n * having to import `useVariantValue` into a component that is\n * otherwise pure JSX. Useful inside dense markup where introducing a\n * hook would require pulling the surrounding code into a wrapper\n * component.\n */\nimport type { ReactNode } from \"react\";\nimport { useVariantValue } from \"../hooks/use-variant-value.js\";\n\nexport interface VariantValueProps<T> {\n readonly experimentId: string;\n readonly children: (value: T) => ReactNode;\n}\n\nexport function VariantValue<T = unknown>({\n experimentId,\n children,\n}: VariantValueProps<T>): ReactNode {\n const value = useVariantValue<T>(experimentId);\n return children(value);\n}\n","/**\n * `useExperiment` — combined read + tracker helper.\n *\n * Returns the variant id, the typed value, and a `track` function\n * bound to this experiment. `track` is a pass-through to the engine's\n * history ring buffer — it doesn't do network IO (core makes no\n * outbound calls, period) but it does give downstream telemetry\n * subscribers something to observe.\n *\n * The `track` closure is stable across re-renders via `useCallback`\n * so passing it into memoized children doesn't bust their memoization.\n */\nimport { useCallback } from \"react\";\nimport { useVariant } from \"./use-variant.js\";\nimport { useVariantLabEngine } from \"./use-variant-lab-engine.js\";\n\nexport interface UseExperimentResult<T> {\n readonly variant: string;\n readonly value: T;\n readonly track: (eventName: string, properties?: Record<string, unknown>) => void;\n}\n\nexport function useExperiment<T = unknown>(experimentId: string): UseExperimentResult<T> {\n const engine = useVariantLabEngine();\n const variant = useVariant(experimentId);\n const value = engine.getVariantValue<T>(experimentId);\n\n const track = useCallback((_eventName: string, _properties?: Record<string, unknown>): void => {\n // Telemetry forwarding is a phase-2 feature. For now `track` is\n // a no-op sink so callers can wire up the API today without\n // waiting for the pluggable telemetry layer.\n }, []);\n\n return { variant, value, track };\n}\n","/**\n * `useRouteExperiments` — list experiments active on a given route.\n *\n * Pure derivation from `engine.getExperiments(route)`. The challenge\n * is that `engine.getExperiments(route)` allocates a fresh filtered\n * array on every call, which would cause `useSyncExternalStore` to\n * flag an infinite update loop. We cache the snapshot in a ref and\n * invalidate it whenever a relevant engine event fires, so the\n * reference is stable between notifications.\n */\n\nimport type { EngineEvent, Experiment } from \"@variantlab/core\";\nimport { useCallback, useRef, useSyncExternalStore } from \"react\";\nimport { useVariantLabEngine } from \"./use-variant-lab-engine.js\";\n\ninterface Cached {\n readonly route: string | undefined;\n readonly snapshot: readonly Experiment[];\n}\n\nexport function useRouteExperiments(route?: string): readonly Experiment[] {\n const engine = useVariantLabEngine();\n const cacheRef = useRef<Cached | null>(null);\n\n const subscribe = useCallback(\n (onStoreChange: () => void): (() => void) => {\n return engine.subscribe((event: EngineEvent) => {\n if (event.type === \"configLoaded\" || event.type === \"contextUpdated\") {\n cacheRef.current = null;\n onStoreChange();\n }\n });\n },\n [engine],\n );\n\n const getSnapshot = useCallback((): readonly Experiment[] => {\n const cached = cacheRef.current;\n if (cached !== null && cached.route === route) return cached.snapshot;\n const snapshot = engine.getExperiments(route);\n cacheRef.current = { route, snapshot };\n return snapshot;\n }, [engine, route]);\n\n return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);\n}\n","/**\n * `useSetVariant` — dev-only imperative variant override.\n *\n * Returns a stable setter bound to the current engine. Intended for\n * debug UIs (Storybook, the overlay, dev tools) and QA scripts, not\n * for production logic. Production code should update the config or\n * targeting, not force variants from components.\n */\nimport { useCallback } from \"react\";\nimport { useVariantLabEngine } from \"./use-variant-lab-engine.js\";\n\nexport function useSetVariant(): (experimentId: string, variantId: string) => void {\n const engine = useVariantLabEngine();\n return useCallback(\n (experimentId: string, variantId: string): void => {\n engine.setVariant(experimentId, variantId);\n },\n [engine],\n );\n}\n","/**\n * `@variantlab/react` — public API barrel.\n *\n * Every adapter in this package is thin: the heavy lifting is done by\n * `@variantlab/core`'s `VariantEngine`, which this adapter wires into\n * React's rendering model. Callers of `useVariant` never see the\n * engine directly unless they opt in via `useVariantLabEngine`.\n */\nexport const VERSION = \"0.0.0\";\n\nexport {\n VariantErrorBoundary,\n type VariantErrorBoundaryProps,\n} from \"./components/error-boundary.js\";\nexport { Variant, type VariantProps } from \"./components/variant.js\";\nexport { VariantValue, type VariantValueProps } from \"./components/variant-value.js\";\nexport {\n VariantLabContext,\n VariantLabProvider,\n type VariantLabProviderProps,\n} from \"./context.js\";\nexport { type UseExperimentResult, useExperiment } from \"./hooks/use-experiment.js\";\nexport { useRouteExperiments } from \"./hooks/use-route-experiments.js\";\nexport { useSetVariant } from \"./hooks/use-set-variant.js\";\nexport { useVariant } from \"./hooks/use-variant.js\";\nexport { useVariantLabEngine } from \"./hooks/use-variant-lab-engine.js\";\nexport { useVariantValue } from \"./hooks/use-variant-value.js\";\n"]}
@@ -0,0 +1,144 @@
1
+ import * as react from 'react';
2
+ import { Component, ReactNode, ErrorInfo } from 'react';
3
+ import { VariantEngine, VariantContext, Experiment } from '@variantlab/core';
4
+
5
+ interface VariantErrorBoundaryProps {
6
+ readonly experimentId: string;
7
+ readonly fallback?: ReactNode | ((error: Error) => ReactNode);
8
+ readonly children: ReactNode;
9
+ }
10
+ interface VariantErrorBoundaryState {
11
+ readonly error: Error | null;
12
+ }
13
+ declare class VariantErrorBoundary extends Component<VariantErrorBoundaryProps, VariantErrorBoundaryState> {
14
+ static contextType: react.Context<VariantEngine | null>;
15
+ context: VariantEngine | null;
16
+ state: VariantErrorBoundaryState;
17
+ static getDerivedStateFromError(error: Error): VariantErrorBoundaryState;
18
+ componentDidCatch(error: Error, _info: ErrorInfo): void;
19
+ componentDidUpdate(prevProps: VariantErrorBoundaryProps): void;
20
+ render(): ReactNode;
21
+ }
22
+
23
+ /**
24
+ * `<Variant>` — render-prop switch for "render" experiments.
25
+ *
26
+ * Takes a plain `Record<variantId, ReactNode>` as its children. The
27
+ * shape intentionally looks like a lookup table so grep, codegen, and
28
+ * TypeScript exhaustiveness checks all work without any custom
29
+ * helpers. If the active variant isn't in the table we render
30
+ * `fallback` if provided, else `null` — silent render failures are
31
+ * preferable to crashing the tree when a config is missing a case.
32
+ *
33
+ * This component is tiny on purpose: the real logic lives in
34
+ * `useVariant`, and everything here is table lookup plus a fallback.
35
+ */
36
+
37
+ interface VariantProps {
38
+ readonly experimentId: string;
39
+ readonly children: Readonly<Record<string, ReactNode>>;
40
+ readonly fallback?: ReactNode;
41
+ }
42
+ declare function Variant({ experimentId, children, fallback }: VariantProps): ReactNode;
43
+
44
+ /**
45
+ * `<VariantValue>` — function-as-child helper for "value" experiments.
46
+ *
47
+ * Lets callers declaratively render with a typed variant value without
48
+ * having to import `useVariantValue` into a component that is
49
+ * otherwise pure JSX. Useful inside dense markup where introducing a
50
+ * hook would require pulling the surrounding code into a wrapper
51
+ * component.
52
+ */
53
+
54
+ interface VariantValueProps<T> {
55
+ readonly experimentId: string;
56
+ readonly children: (value: T) => ReactNode;
57
+ }
58
+ declare function VariantValue<T = unknown>({ experimentId, children, }: VariantValueProps<T>): ReactNode;
59
+
60
+ /** The raw context value. `null` until a provider wraps the tree. */
61
+ declare const VariantLabContext: react.Context<VariantEngine | null>;
62
+ interface VariantLabProviderProps {
63
+ /** A pre-constructed engine from `createEngine(...)`. */
64
+ readonly engine: VariantEngine;
65
+ /** Optional runtime context applied once on mount. */
66
+ readonly initialContext?: VariantContext;
67
+ readonly children?: ReactNode;
68
+ }
69
+ /**
70
+ * Wraps descendants so the hooks in this package can locate the engine.
71
+ *
72
+ * The value handed to `Context.Provider` is memoized on `engine` alone
73
+ * so that unrelated parent re-renders don't force a new reference
74
+ * (which would cascade through every `useVariant` consumer).
75
+ */
76
+ declare function VariantLabProvider({ engine, initialContext, children, }: VariantLabProviderProps): ReactNode;
77
+
78
+ interface UseExperimentResult<T> {
79
+ readonly variant: string;
80
+ readonly value: T;
81
+ readonly track: (eventName: string, properties?: Record<string, unknown>) => void;
82
+ }
83
+ declare function useExperiment<T = unknown>(experimentId: string): UseExperimentResult<T>;
84
+
85
+ /**
86
+ * `useRouteExperiments` — list experiments active on a given route.
87
+ *
88
+ * Pure derivation from `engine.getExperiments(route)`. The challenge
89
+ * is that `engine.getExperiments(route)` allocates a fresh filtered
90
+ * array on every call, which would cause `useSyncExternalStore` to
91
+ * flag an infinite update loop. We cache the snapshot in a ref and
92
+ * invalidate it whenever a relevant engine event fires, so the
93
+ * reference is stable between notifications.
94
+ */
95
+
96
+ declare function useRouteExperiments(route?: string): readonly Experiment[];
97
+
98
+ declare function useSetVariant(): (experimentId: string, variantId: string) => void;
99
+
100
+ /**
101
+ * `useVariant` — read the current variant id for an experiment.
102
+ *
103
+ * Subscribes to engine events via `useSyncExternalStore` so that the
104
+ * component re-renders whenever the variant for this experiment
105
+ * actually changes (manual override, rollback, config reload, context
106
+ * update). Events that don't affect this experiment are filtered out
107
+ * at the listener level — React's bail-out on identical snapshots
108
+ * handles the rest.
109
+ *
110
+ * The snapshot callback is `engine.getVariant(id)` directly. The
111
+ * engine memoizes the result in an internal cache, so repeated reads
112
+ * in the same render pass are O(1) and side-effect free — which is
113
+ * exactly what `useSyncExternalStore` requires.
114
+ */
115
+ declare function useVariant(experimentId: string): string;
116
+
117
+ /**
118
+ * `useVariantLabEngine` — raw engine access escape hatch.
119
+ *
120
+ * Most consumers should reach for `useVariant` / `useVariantValue` /
121
+ * `useExperiment` instead. This hook is for code that needs to call
122
+ * engine methods directly (e.g. `setVariant` from a debug panel, or
123
+ * `reportCrash` from a custom error boundary).
124
+ *
125
+ * Throws synchronously when called outside a `VariantLabProvider`
126
+ * because any other behavior (returning `null`, lazy init) would hide
127
+ * the real bug behind mysterious fallbacks elsewhere in the tree.
128
+ */
129
+
130
+ declare function useVariantLabEngine(): VariantEngine;
131
+
132
+ declare function useVariantValue<T = unknown>(experimentId: string): T;
133
+
134
+ /**
135
+ * `@variantlab/react` — public API barrel.
136
+ *
137
+ * Every adapter in this package is thin: the heavy lifting is done by
138
+ * `@variantlab/core`'s `VariantEngine`, which this adapter wires into
139
+ * React's rendering model. Callers of `useVariant` never see the
140
+ * engine directly unless they opt in via `useVariantLabEngine`.
141
+ */
142
+ declare const VERSION = "0.0.0";
143
+
144
+ export { type UseExperimentResult, VERSION, Variant, VariantErrorBoundary, type VariantErrorBoundaryProps, VariantLabContext, VariantLabProvider, type VariantLabProviderProps, type VariantProps, VariantValue, type VariantValueProps, useExperiment, useRouteExperiments, useSetVariant, useVariant, useVariantLabEngine, useVariantValue };
@@ -0,0 +1,144 @@
1
+ import * as react from 'react';
2
+ import { Component, ReactNode, ErrorInfo } from 'react';
3
+ import { VariantEngine, VariantContext, Experiment } from '@variantlab/core';
4
+
5
+ interface VariantErrorBoundaryProps {
6
+ readonly experimentId: string;
7
+ readonly fallback?: ReactNode | ((error: Error) => ReactNode);
8
+ readonly children: ReactNode;
9
+ }
10
+ interface VariantErrorBoundaryState {
11
+ readonly error: Error | null;
12
+ }
13
+ declare class VariantErrorBoundary extends Component<VariantErrorBoundaryProps, VariantErrorBoundaryState> {
14
+ static contextType: react.Context<VariantEngine | null>;
15
+ context: VariantEngine | null;
16
+ state: VariantErrorBoundaryState;
17
+ static getDerivedStateFromError(error: Error): VariantErrorBoundaryState;
18
+ componentDidCatch(error: Error, _info: ErrorInfo): void;
19
+ componentDidUpdate(prevProps: VariantErrorBoundaryProps): void;
20
+ render(): ReactNode;
21
+ }
22
+
23
+ /**
24
+ * `<Variant>` — render-prop switch for "render" experiments.
25
+ *
26
+ * Takes a plain `Record<variantId, ReactNode>` as its children. The
27
+ * shape intentionally looks like a lookup table so grep, codegen, and
28
+ * TypeScript exhaustiveness checks all work without any custom
29
+ * helpers. If the active variant isn't in the table we render
30
+ * `fallback` if provided, else `null` — silent render failures are
31
+ * preferable to crashing the tree when a config is missing a case.
32
+ *
33
+ * This component is tiny on purpose: the real logic lives in
34
+ * `useVariant`, and everything here is table lookup plus a fallback.
35
+ */
36
+
37
+ interface VariantProps {
38
+ readonly experimentId: string;
39
+ readonly children: Readonly<Record<string, ReactNode>>;
40
+ readonly fallback?: ReactNode;
41
+ }
42
+ declare function Variant({ experimentId, children, fallback }: VariantProps): ReactNode;
43
+
44
+ /**
45
+ * `<VariantValue>` — function-as-child helper for "value" experiments.
46
+ *
47
+ * Lets callers declaratively render with a typed variant value without
48
+ * having to import `useVariantValue` into a component that is
49
+ * otherwise pure JSX. Useful inside dense markup where introducing a
50
+ * hook would require pulling the surrounding code into a wrapper
51
+ * component.
52
+ */
53
+
54
+ interface VariantValueProps<T> {
55
+ readonly experimentId: string;
56
+ readonly children: (value: T) => ReactNode;
57
+ }
58
+ declare function VariantValue<T = unknown>({ experimentId, children, }: VariantValueProps<T>): ReactNode;
59
+
60
+ /** The raw context value. `null` until a provider wraps the tree. */
61
+ declare const VariantLabContext: react.Context<VariantEngine | null>;
62
+ interface VariantLabProviderProps {
63
+ /** A pre-constructed engine from `createEngine(...)`. */
64
+ readonly engine: VariantEngine;
65
+ /** Optional runtime context applied once on mount. */
66
+ readonly initialContext?: VariantContext;
67
+ readonly children?: ReactNode;
68
+ }
69
+ /**
70
+ * Wraps descendants so the hooks in this package can locate the engine.
71
+ *
72
+ * The value handed to `Context.Provider` is memoized on `engine` alone
73
+ * so that unrelated parent re-renders don't force a new reference
74
+ * (which would cascade through every `useVariant` consumer).
75
+ */
76
+ declare function VariantLabProvider({ engine, initialContext, children, }: VariantLabProviderProps): ReactNode;
77
+
78
+ interface UseExperimentResult<T> {
79
+ readonly variant: string;
80
+ readonly value: T;
81
+ readonly track: (eventName: string, properties?: Record<string, unknown>) => void;
82
+ }
83
+ declare function useExperiment<T = unknown>(experimentId: string): UseExperimentResult<T>;
84
+
85
+ /**
86
+ * `useRouteExperiments` — list experiments active on a given route.
87
+ *
88
+ * Pure derivation from `engine.getExperiments(route)`. The challenge
89
+ * is that `engine.getExperiments(route)` allocates a fresh filtered
90
+ * array on every call, which would cause `useSyncExternalStore` to
91
+ * flag an infinite update loop. We cache the snapshot in a ref and
92
+ * invalidate it whenever a relevant engine event fires, so the
93
+ * reference is stable between notifications.
94
+ */
95
+
96
+ declare function useRouteExperiments(route?: string): readonly Experiment[];
97
+
98
+ declare function useSetVariant(): (experimentId: string, variantId: string) => void;
99
+
100
+ /**
101
+ * `useVariant` — read the current variant id for an experiment.
102
+ *
103
+ * Subscribes to engine events via `useSyncExternalStore` so that the
104
+ * component re-renders whenever the variant for this experiment
105
+ * actually changes (manual override, rollback, config reload, context
106
+ * update). Events that don't affect this experiment are filtered out
107
+ * at the listener level — React's bail-out on identical snapshots
108
+ * handles the rest.
109
+ *
110
+ * The snapshot callback is `engine.getVariant(id)` directly. The
111
+ * engine memoizes the result in an internal cache, so repeated reads
112
+ * in the same render pass are O(1) and side-effect free — which is
113
+ * exactly what `useSyncExternalStore` requires.
114
+ */
115
+ declare function useVariant(experimentId: string): string;
116
+
117
+ /**
118
+ * `useVariantLabEngine` — raw engine access escape hatch.
119
+ *
120
+ * Most consumers should reach for `useVariant` / `useVariantValue` /
121
+ * `useExperiment` instead. This hook is for code that needs to call
122
+ * engine methods directly (e.g. `setVariant` from a debug panel, or
123
+ * `reportCrash` from a custom error boundary).
124
+ *
125
+ * Throws synchronously when called outside a `VariantLabProvider`
126
+ * because any other behavior (returning `null`, lazy init) would hide
127
+ * the real bug behind mysterious fallbacks elsewhere in the tree.
128
+ */
129
+
130
+ declare function useVariantLabEngine(): VariantEngine;
131
+
132
+ declare function useVariantValue<T = unknown>(experimentId: string): T;
133
+
134
+ /**
135
+ * `@variantlab/react` — public API barrel.
136
+ *
137
+ * Every adapter in this package is thin: the heavy lifting is done by
138
+ * `@variantlab/core`'s `VariantEngine`, which this adapter wires into
139
+ * React's rendering model. Callers of `useVariant` never see the
140
+ * engine directly unless they opt in via `useVariantLabEngine`.
141
+ */
142
+ declare const VERSION = "0.0.0";
143
+
144
+ export { type UseExperimentResult, VERSION, Variant, VariantErrorBoundary, type VariantErrorBoundaryProps, VariantLabContext, VariantLabProvider, type VariantLabProviderProps, type VariantProps, VariantValue, type VariantValueProps, useExperiment, useRouteExperiments, useSetVariant, useVariant, useVariantLabEngine, useVariantValue };
package/dist/index.js ADDED
@@ -0,0 +1,167 @@
1
+ import { createContext, Component, useMemo, useRef, useLayoutEffect, useContext, useCallback, useSyncExternalStore } from 'react';
2
+ import { jsx } from 'react/jsx-runtime';
3
+
4
+ // src/components/error-boundary.tsx
5
+ var VariantLabContext = createContext(null);
6
+ function VariantLabProvider({
7
+ engine,
8
+ initialContext,
9
+ children
10
+ }) {
11
+ const value = useMemo(() => engine, [engine]);
12
+ const appliedRef = useRef(null);
13
+ useLayoutEffect(() => {
14
+ if (initialContext === void 0) return;
15
+ const prev = appliedRef.current;
16
+ if (prev !== null && prev.engine === engine && prev.ctx === initialContext) {
17
+ return;
18
+ }
19
+ engine.updateContext(initialContext);
20
+ appliedRef.current = { engine, ctx: initialContext };
21
+ }, [engine, initialContext]);
22
+ return /* @__PURE__ */ jsx(VariantLabContext.Provider, { value, children });
23
+ }
24
+
25
+ // src/components/error-boundary.tsx
26
+ var VariantErrorBoundary = class extends Component {
27
+ constructor() {
28
+ super(...arguments);
29
+ this.state = { error: null };
30
+ }
31
+ static getDerivedStateFromError(error) {
32
+ return { error };
33
+ }
34
+ componentDidCatch(error, _info) {
35
+ const engine = this.context;
36
+ if (engine !== null) {
37
+ try {
38
+ engine.reportCrash(this.props.experimentId, error);
39
+ } catch {
40
+ }
41
+ }
42
+ }
43
+ componentDidUpdate(prevProps) {
44
+ if (this.state.error !== null && prevProps.children !== this.props.children) {
45
+ this.setState({ error: null });
46
+ }
47
+ }
48
+ render() {
49
+ const { error } = this.state;
50
+ if (error !== null) {
51
+ const { fallback } = this.props;
52
+ if (typeof fallback === "function") return fallback(error);
53
+ return fallback ?? null;
54
+ }
55
+ return this.props.children;
56
+ }
57
+ };
58
+ VariantErrorBoundary.contextType = VariantLabContext;
59
+ function useVariantLabEngine() {
60
+ const engine = useContext(VariantLabContext);
61
+ if (engine === null) {
62
+ throw new Error("useVariantLabEngine: no <VariantLabProvider> found above this component.");
63
+ }
64
+ return engine;
65
+ }
66
+
67
+ // src/hooks/use-variant.ts
68
+ function useVariant(experimentId) {
69
+ const engine = useVariantLabEngine();
70
+ const subscribe = useCallback(
71
+ (onStoreChange) => {
72
+ return engine.subscribe((event) => {
73
+ if (shouldNotify(event, experimentId)) onStoreChange();
74
+ });
75
+ },
76
+ [engine, experimentId]
77
+ );
78
+ const getSnapshot = useCallback(
79
+ () => engine.getVariant(experimentId),
80
+ [engine, experimentId]
81
+ );
82
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
83
+ }
84
+ function shouldNotify(event, experimentId) {
85
+ switch (event.type) {
86
+ case "configLoaded":
87
+ case "contextUpdated":
88
+ return true;
89
+ case "variantChanged":
90
+ case "rollback":
91
+ case "assignment":
92
+ return event.experimentId === experimentId;
93
+ default:
94
+ return false;
95
+ }
96
+ }
97
+
98
+ // src/components/variant.tsx
99
+ function Variant({ experimentId, children, fallback }) {
100
+ const active = useVariant(experimentId);
101
+ const node = children[active];
102
+ if (node !== void 0) return node;
103
+ return fallback ?? null;
104
+ }
105
+
106
+ // src/hooks/use-variant-value.ts
107
+ function useVariantValue(experimentId) {
108
+ const engine = useVariantLabEngine();
109
+ useVariant(experimentId);
110
+ return engine.getVariantValue(experimentId);
111
+ }
112
+
113
+ // src/components/variant-value.tsx
114
+ function VariantValue({
115
+ experimentId,
116
+ children
117
+ }) {
118
+ const value = useVariantValue(experimentId);
119
+ return children(value);
120
+ }
121
+ function useExperiment(experimentId) {
122
+ const engine = useVariantLabEngine();
123
+ const variant = useVariant(experimentId);
124
+ const value = engine.getVariantValue(experimentId);
125
+ const track = useCallback((_eventName, _properties) => {
126
+ }, []);
127
+ return { variant, value, track };
128
+ }
129
+ function useRouteExperiments(route) {
130
+ const engine = useVariantLabEngine();
131
+ const cacheRef = useRef(null);
132
+ const subscribe = useCallback(
133
+ (onStoreChange) => {
134
+ return engine.subscribe((event) => {
135
+ if (event.type === "configLoaded" || event.type === "contextUpdated") {
136
+ cacheRef.current = null;
137
+ onStoreChange();
138
+ }
139
+ });
140
+ },
141
+ [engine]
142
+ );
143
+ const getSnapshot = useCallback(() => {
144
+ const cached = cacheRef.current;
145
+ if (cached !== null && cached.route === route) return cached.snapshot;
146
+ const snapshot = engine.getExperiments(route);
147
+ cacheRef.current = { route, snapshot };
148
+ return snapshot;
149
+ }, [engine, route]);
150
+ return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
151
+ }
152
+ function useSetVariant() {
153
+ const engine = useVariantLabEngine();
154
+ return useCallback(
155
+ (experimentId, variantId) => {
156
+ engine.setVariant(experimentId, variantId);
157
+ },
158
+ [engine]
159
+ );
160
+ }
161
+
162
+ // src/index.ts
163
+ var VERSION = "0.0.0";
164
+
165
+ export { VERSION, Variant, VariantErrorBoundary, VariantLabContext, VariantLabProvider, VariantValue, useExperiment, useRouteExperiments, useSetVariant, useVariant, useVariantLabEngine, useVariantValue };
166
+ //# sourceMappingURL=index.js.map
167
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/context.tsx","../src/components/error-boundary.tsx","../src/hooks/use-variant-lab-engine.ts","../src/hooks/use-variant.ts","../src/components/variant.tsx","../src/hooks/use-variant-value.ts","../src/components/variant-value.tsx","../src/hooks/use-experiment.ts","../src/hooks/use-route-experiments.ts","../src/hooks/use-set-variant.ts","../src/index.ts"],"names":["useCallback","useRef","useSyncExternalStore"],"mappings":"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","file":"index.js","sourcesContent":["/**\n * React context and provider for variantlab.\n *\n * The provider is a thin wrapper that hands a pre-built `VariantEngine`\n * to descendants via React context. It intentionally does NOT own the\n * engine: callers construct it via `createEngine(...)` and pass it in.\n * That keeps the adapter testable (swap in a mock engine), SSR-safe\n * (server code can build an engine before render), and free of\n * lifecycle surprises — unmounting the provider never disposes the\n * engine because the provider didn't create it.\n *\n * `initialContext`, when supplied, is applied via `useLayoutEffect`\n * before the first browser paint. We use a ref to guard against\n * repeated application on re-renders so the engine cache is only\n * busted when the caller actually swaps engines.\n */\n\nimport type { VariantContext, VariantEngine } from \"@variantlab/core\";\nimport { createContext, type ReactNode, useLayoutEffect, useMemo, useRef } from \"react\";\n\n/** The raw context value. `null` until a provider wraps the tree. */\nexport const VariantLabContext = createContext<VariantEngine | null>(null);\n\nexport interface VariantLabProviderProps {\n /** A pre-constructed engine from `createEngine(...)`. */\n readonly engine: VariantEngine;\n /** Optional runtime context applied once on mount. */\n readonly initialContext?: VariantContext;\n readonly children?: ReactNode;\n}\n\n/**\n * Wraps descendants so the hooks in this package can locate the engine.\n *\n * The value handed to `Context.Provider` is memoized on `engine` alone\n * so that unrelated parent re-renders don't force a new reference\n * (which would cascade through every `useVariant` consumer).\n */\nexport function VariantLabProvider({\n engine,\n initialContext,\n children,\n}: VariantLabProviderProps): ReactNode {\n // Stable context value: only changes when the engine itself changes.\n const value = useMemo(() => engine, [engine]);\n\n // Track which (engine, initialContext) pair we've already applied so\n // re-renders don't re-invoke updateContext (which would clear the\n // engine cache and cause unnecessary reassignment events).\n const appliedRef = useRef<{\n readonly engine: VariantEngine;\n readonly ctx: VariantContext | undefined;\n } | null>(null);\n\n useLayoutEffect(() => {\n if (initialContext === undefined) return;\n const prev = appliedRef.current;\n if (prev !== null && prev.engine === engine && prev.ctx === initialContext) {\n return;\n }\n engine.updateContext(initialContext);\n appliedRef.current = { engine, ctx: initialContext };\n }, [engine, initialContext]);\n\n return <VariantLabContext.Provider value={value}>{children}</VariantLabContext.Provider>;\n}\n","/**\n * `<VariantErrorBoundary>` — crash insulation for experiment render trees.\n *\n * Wraps a variant's subtree in a class-based error boundary (the only\n * remaining legitimate use of class components in modern React). When\n * a child throws, it:\n *\n * 1. reports the crash to the engine via `engine.reportCrash(id, err)`,\n * which feeds the rollback counter defined in `config-format.md`;\n * 2. renders `fallback` (or `null`) for the rest of the current\n * commit cycle;\n * 3. resets itself so the next render can try again with the new\n * variant (likely the rolled-back default after N crashes).\n *\n * The engine lookup goes through `VariantLabContext` rather than a\n * prop so the ergonomics match the hook-first API. `contextType` on\n * the class makes the context value available as `this.context` in\n * `componentDidCatch`.\n */\n\nimport type { VariantEngine } from \"@variantlab/core\";\nimport { Component, type ErrorInfo, type ReactNode } from \"react\";\nimport { VariantLabContext } from \"../context.js\";\n\nexport interface VariantErrorBoundaryProps {\n readonly experimentId: string;\n readonly fallback?: ReactNode | ((error: Error) => ReactNode);\n readonly children: ReactNode;\n}\n\ninterface VariantErrorBoundaryState {\n readonly error: Error | null;\n}\n\nexport class VariantErrorBoundary extends Component<\n VariantErrorBoundaryProps,\n VariantErrorBoundaryState\n> {\n static override contextType = VariantLabContext;\n declare context: VariantEngine | null;\n override state: VariantErrorBoundaryState = { error: null };\n\n static getDerivedStateFromError(error: Error): VariantErrorBoundaryState {\n return { error };\n }\n\n override componentDidCatch(error: Error, _info: ErrorInfo): void {\n const engine = this.context;\n if (engine !== null) {\n try {\n engine.reportCrash(this.props.experimentId, error);\n } catch {\n // Swallow crash-reporting failures — we must not double-throw\n // inside an error boundary.\n }\n }\n }\n\n override componentDidUpdate(prevProps: VariantErrorBoundaryProps): void {\n // If the children prop changes after a recovery (e.g. the engine\n // rolled back and a parent re-rendered with a different subtree),\n // clear the error so we attempt to render the new tree.\n if (this.state.error !== null && prevProps.children !== this.props.children) {\n this.setState({ error: null });\n }\n }\n\n override render(): ReactNode {\n const { error } = this.state;\n if (error !== null) {\n const { fallback } = this.props;\n if (typeof fallback === \"function\") return fallback(error);\n return fallback ?? null;\n }\n return this.props.children;\n }\n}\n","/**\n * `useVariantLabEngine` — raw engine access escape hatch.\n *\n * Most consumers should reach for `useVariant` / `useVariantValue` /\n * `useExperiment` instead. This hook is for code that needs to call\n * engine methods directly (e.g. `setVariant` from a debug panel, or\n * `reportCrash` from a custom error boundary).\n *\n * Throws synchronously when called outside a `VariantLabProvider`\n * because any other behavior (returning `null`, lazy init) would hide\n * the real bug behind mysterious fallbacks elsewhere in the tree.\n */\n\nimport type { VariantEngine } from \"@variantlab/core\";\nimport { useContext } from \"react\";\nimport { VariantLabContext } from \"../context.js\";\n\nexport function useVariantLabEngine(): VariantEngine {\n const engine = useContext(VariantLabContext);\n if (engine === null) {\n throw new Error(\"useVariantLabEngine: no <VariantLabProvider> found above this component.\");\n }\n return engine;\n}\n","/**\n * `useVariant` — read the current variant id for an experiment.\n *\n * Subscribes to engine events via `useSyncExternalStore` so that the\n * component re-renders whenever the variant for this experiment\n * actually changes (manual override, rollback, config reload, context\n * update). Events that don't affect this experiment are filtered out\n * at the listener level — React's bail-out on identical snapshots\n * handles the rest.\n *\n * The snapshot callback is `engine.getVariant(id)` directly. The\n * engine memoizes the result in an internal cache, so repeated reads\n * in the same render pass are O(1) and side-effect free — which is\n * exactly what `useSyncExternalStore` requires.\n */\n\nimport type { EngineEvent } from \"@variantlab/core\";\nimport { useCallback, useSyncExternalStore } from \"react\";\nimport { useVariantLabEngine } from \"./use-variant-lab-engine.js\";\n\nexport function useVariant(experimentId: string): string {\n const engine = useVariantLabEngine();\n\n const subscribe = useCallback(\n (onStoreChange: () => void): (() => void) => {\n return engine.subscribe((event: EngineEvent) => {\n if (shouldNotify(event, experimentId)) onStoreChange();\n });\n },\n [engine, experimentId],\n );\n\n const getSnapshot = useCallback(\n (): string => engine.getVariant(experimentId),\n [engine, experimentId],\n );\n\n return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);\n}\n\n/**\n * Decide whether an engine event should retrigger snapshot sampling.\n * Broad events (`configLoaded`, `contextUpdated`) always fire; targeted\n * events fire only when they name this experiment.\n */\nfunction shouldNotify(event: EngineEvent, experimentId: string): boolean {\n switch (event.type) {\n case \"configLoaded\":\n case \"contextUpdated\":\n return true;\n case \"variantChanged\":\n case \"rollback\":\n case \"assignment\":\n return event.experimentId === experimentId;\n default:\n return false;\n }\n}\n","/**\n * `<Variant>` — render-prop switch for \"render\" experiments.\n *\n * Takes a plain `Record<variantId, ReactNode>` as its children. The\n * shape intentionally looks like a lookup table so grep, codegen, and\n * TypeScript exhaustiveness checks all work without any custom\n * helpers. If the active variant isn't in the table we render\n * `fallback` if provided, else `null` — silent render failures are\n * preferable to crashing the tree when a config is missing a case.\n *\n * This component is tiny on purpose: the real logic lives in\n * `useVariant`, and everything here is table lookup plus a fallback.\n */\nimport type { ReactNode } from \"react\";\nimport { useVariant } from \"../hooks/use-variant.js\";\n\nexport interface VariantProps {\n readonly experimentId: string;\n readonly children: Readonly<Record<string, ReactNode>>;\n readonly fallback?: ReactNode;\n}\n\nexport function Variant({ experimentId, children, fallback }: VariantProps): ReactNode {\n const active = useVariant(experimentId);\n const node = children[active];\n if (node !== undefined) return node;\n return fallback ?? null;\n}\n","/**\n * `useVariantValue` — read a \"value\" experiment's typed payload.\n *\n * Thin wrapper around `useVariant` that looks up the matching\n * `variant.value` from the engine. Used for copy, pricing, feature\n * toggles, and any other experiment where the thing that varies is\n * data, not a React component.\n *\n * The generic `T` exists purely for type inference ergonomics. When\n * codegen is active it's filled in automatically from\n * `GeneratedExperiments[id][\"value\"]`; otherwise callers pass an\n * explicit type parameter.\n */\nimport { useVariant } from \"./use-variant.js\";\nimport { useVariantLabEngine } from \"./use-variant-lab-engine.js\";\n\nexport function useVariantValue<T = unknown>(experimentId: string): T {\n const engine = useVariantLabEngine();\n // Track the variant id so we re-render when it changes. The actual\n // value is fetched via `getVariantValue` which walks the variant\n // table; this is O(n_variants) which is fine for typical configs.\n useVariant(experimentId);\n return engine.getVariantValue<T>(experimentId);\n}\n","/**\n * `<VariantValue>` — function-as-child helper for \"value\" experiments.\n *\n * Lets callers declaratively render with a typed variant value without\n * having to import `useVariantValue` into a component that is\n * otherwise pure JSX. Useful inside dense markup where introducing a\n * hook would require pulling the surrounding code into a wrapper\n * component.\n */\nimport type { ReactNode } from \"react\";\nimport { useVariantValue } from \"../hooks/use-variant-value.js\";\n\nexport interface VariantValueProps<T> {\n readonly experimentId: string;\n readonly children: (value: T) => ReactNode;\n}\n\nexport function VariantValue<T = unknown>({\n experimentId,\n children,\n}: VariantValueProps<T>): ReactNode {\n const value = useVariantValue<T>(experimentId);\n return children(value);\n}\n","/**\n * `useExperiment` — combined read + tracker helper.\n *\n * Returns the variant id, the typed value, and a `track` function\n * bound to this experiment. `track` is a pass-through to the engine's\n * history ring buffer — it doesn't do network IO (core makes no\n * outbound calls, period) but it does give downstream telemetry\n * subscribers something to observe.\n *\n * The `track` closure is stable across re-renders via `useCallback`\n * so passing it into memoized children doesn't bust their memoization.\n */\nimport { useCallback } from \"react\";\nimport { useVariant } from \"./use-variant.js\";\nimport { useVariantLabEngine } from \"./use-variant-lab-engine.js\";\n\nexport interface UseExperimentResult<T> {\n readonly variant: string;\n readonly value: T;\n readonly track: (eventName: string, properties?: Record<string, unknown>) => void;\n}\n\nexport function useExperiment<T = unknown>(experimentId: string): UseExperimentResult<T> {\n const engine = useVariantLabEngine();\n const variant = useVariant(experimentId);\n const value = engine.getVariantValue<T>(experimentId);\n\n const track = useCallback((_eventName: string, _properties?: Record<string, unknown>): void => {\n // Telemetry forwarding is a phase-2 feature. For now `track` is\n // a no-op sink so callers can wire up the API today without\n // waiting for the pluggable telemetry layer.\n }, []);\n\n return { variant, value, track };\n}\n","/**\n * `useRouteExperiments` — list experiments active on a given route.\n *\n * Pure derivation from `engine.getExperiments(route)`. The challenge\n * is that `engine.getExperiments(route)` allocates a fresh filtered\n * array on every call, which would cause `useSyncExternalStore` to\n * flag an infinite update loop. We cache the snapshot in a ref and\n * invalidate it whenever a relevant engine event fires, so the\n * reference is stable between notifications.\n */\n\nimport type { EngineEvent, Experiment } from \"@variantlab/core\";\nimport { useCallback, useRef, useSyncExternalStore } from \"react\";\nimport { useVariantLabEngine } from \"./use-variant-lab-engine.js\";\n\ninterface Cached {\n readonly route: string | undefined;\n readonly snapshot: readonly Experiment[];\n}\n\nexport function useRouteExperiments(route?: string): readonly Experiment[] {\n const engine = useVariantLabEngine();\n const cacheRef = useRef<Cached | null>(null);\n\n const subscribe = useCallback(\n (onStoreChange: () => void): (() => void) => {\n return engine.subscribe((event: EngineEvent) => {\n if (event.type === \"configLoaded\" || event.type === \"contextUpdated\") {\n cacheRef.current = null;\n onStoreChange();\n }\n });\n },\n [engine],\n );\n\n const getSnapshot = useCallback((): readonly Experiment[] => {\n const cached = cacheRef.current;\n if (cached !== null && cached.route === route) return cached.snapshot;\n const snapshot = engine.getExperiments(route);\n cacheRef.current = { route, snapshot };\n return snapshot;\n }, [engine, route]);\n\n return useSyncExternalStore(subscribe, getSnapshot, getSnapshot);\n}\n","/**\n * `useSetVariant` — dev-only imperative variant override.\n *\n * Returns a stable setter bound to the current engine. Intended for\n * debug UIs (Storybook, the overlay, dev tools) and QA scripts, not\n * for production logic. Production code should update the config or\n * targeting, not force variants from components.\n */\nimport { useCallback } from \"react\";\nimport { useVariantLabEngine } from \"./use-variant-lab-engine.js\";\n\nexport function useSetVariant(): (experimentId: string, variantId: string) => void {\n const engine = useVariantLabEngine();\n return useCallback(\n (experimentId: string, variantId: string): void => {\n engine.setVariant(experimentId, variantId);\n },\n [engine],\n );\n}\n","/**\n * `@variantlab/react` — public API barrel.\n *\n * Every adapter in this package is thin: the heavy lifting is done by\n * `@variantlab/core`'s `VariantEngine`, which this adapter wires into\n * React's rendering model. Callers of `useVariant` never see the\n * engine directly unless they opt in via `useVariantLabEngine`.\n */\nexport const VERSION = \"0.0.0\";\n\nexport {\n VariantErrorBoundary,\n type VariantErrorBoundaryProps,\n} from \"./components/error-boundary.js\";\nexport { Variant, type VariantProps } from \"./components/variant.js\";\nexport { VariantValue, type VariantValueProps } from \"./components/variant-value.js\";\nexport {\n VariantLabContext,\n VariantLabProvider,\n type VariantLabProviderProps,\n} from \"./context.js\";\nexport { type UseExperimentResult, useExperiment } from \"./hooks/use-experiment.js\";\nexport { useRouteExperiments } from \"./hooks/use-route-experiments.js\";\nexport { useSetVariant } from \"./hooks/use-set-variant.js\";\nexport { useVariant } from \"./hooks/use-variant.js\";\nexport { useVariantLabEngine } from \"./hooks/use-variant-lab-engine.js\";\nexport { useVariantValue } from \"./hooks/use-variant-value.js\";\n"]}
package/package.json ADDED
@@ -0,0 +1,74 @@
1
+ {
2
+ "name": "@variantlab/react",
3
+ "version": "0.1.0",
4
+ "description": "React hooks and components for variantlab.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "main": "./dist/index.cjs",
9
+ "module": "./dist/index.js",
10
+ "types": "./dist/index.d.ts",
11
+ "exports": {
12
+ ".": {
13
+ "import": {
14
+ "types": "./dist/index.d.ts",
15
+ "default": "./dist/index.js"
16
+ },
17
+ "require": {
18
+ "types": "./dist/index.d.cts",
19
+ "default": "./dist/index.cjs"
20
+ }
21
+ },
22
+ "./package.json": "./package.json"
23
+ },
24
+ "files": [
25
+ "dist",
26
+ "README.md"
27
+ ],
28
+ "publishConfig": {
29
+ "access": "public",
30
+ "provenance": false
31
+ },
32
+ "keywords": [
33
+ "ab-testing",
34
+ "experiments",
35
+ "feature-flags",
36
+ "variants",
37
+ "variantlab",
38
+ "react",
39
+ "hooks"
40
+ ],
41
+ "repository": {
42
+ "type": "git",
43
+ "url": "git+https://github.com/variantlab/variantlab.git",
44
+ "directory": "packages/react"
45
+ },
46
+ "bugs": {
47
+ "url": "https://github.com/variantlab/variantlab/issues"
48
+ },
49
+ "homepage": "https://github.com/variantlab/variantlab#readme",
50
+ "engines": {
51
+ "node": ">=18.17"
52
+ },
53
+ "dependencies": {
54
+ "@variantlab/core": "0.1.0"
55
+ },
56
+ "peerDependencies": {
57
+ "react": "^18.2.0 || ^19.0.0"
58
+ },
59
+ "devDependencies": {
60
+ "@testing-library/react": "^16.1.0",
61
+ "@types/react": "^19.0.0",
62
+ "@types/react-dom": "^19.0.0",
63
+ "jsdom": "^25.0.1",
64
+ "react": "^19.0.0",
65
+ "react-dom": "^19.0.0"
66
+ },
67
+ "scripts": {
68
+ "build": "tsup",
69
+ "typecheck": "tsc --noEmit",
70
+ "test": "pnpm --dir ../.. exec vitest run --project react",
71
+ "test:coverage": "pnpm --dir ../.. exec vitest run --coverage --project react",
72
+ "clean": "rm -rf dist"
73
+ }
74
+ }