@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 +21 -0
- package/README.md +11 -0
- package/dist/index.cjs +180 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +144 -0
- package/dist/index.d.ts +144 -0
- package/dist/index.js +167 -0
- package/dist/index.js.map +1 -0
- package/package.json +74 -0
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"]}
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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
|
+
}
|