@probat/react 0.4.0 → 0.4.2

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.
@@ -1,46 +1,8 @@
1
1
  "use client";
2
2
 
3
- import React, {
4
- useEffect,
5
- useRef,
6
- useState,
7
- useCallback,
8
- useMemo,
9
- } from "react";
10
- import { useProbatContext } from "../context/ProbatContext";
11
- import { fetchDecision, sendMetric, extractClickMeta } from "../utils/api";
12
- import { getDistinctId, getPageKey } from "../utils/eventContext";
13
- import { makeDedupeKey, hasSeen, markSeen } from "../utils/dedupeStorage";
14
- import { useStableInstanceId } from "../utils/stableInstanceId";
15
-
16
- // ── localStorage assignment cache ──────────────────────────────────────────
17
-
18
- const ASSIGNMENT_PREFIX = "probat:assignment:";
19
-
20
- interface StoredAssignment {
21
- variantKey: string;
22
- ts: number;
23
- }
24
-
25
- function readAssignment(id: string): string | null {
26
- if (typeof window === "undefined") return null;
27
- try {
28
- const raw = localStorage.getItem(ASSIGNMENT_PREFIX + id);
29
- if (!raw) return null;
30
- const parsed: StoredAssignment = JSON.parse(raw);
31
- return parsed.variantKey ?? null;
32
- } catch {
33
- return null;
34
- }
35
- }
36
-
37
- function writeAssignment(id: string, variantKey: string): void {
38
- if (typeof window === "undefined") return;
39
- try {
40
- const entry: StoredAssignment = { variantKey, ts: Date.now() };
41
- localStorage.setItem(ASSIGNMENT_PREFIX + id, JSON.stringify(entry));
42
- } catch {}
43
- }
3
+ import React from "react";
4
+ import { useExperiment } from "../hooks/useExperiment";
5
+ import { useTrack } from "../hooks/useTrack";
44
6
 
45
7
  // ── Types ──────────────────────────────────────────────────────────────────
46
8
 
@@ -81,193 +43,32 @@ export function Experiment({
81
43
  fallback = "control",
82
44
  debug = false,
83
45
  }: ExperimentProps) {
84
- const { host, bootstrap, customerId } = useProbatContext();
85
-
86
- // Stable instance id (useId + sessionStorage for cross-mount stability)
87
- const autoInstanceId = useStableInstanceId(id);
88
- const instanceId = componentInstanceId ?? autoInstanceId;
89
-
90
- // Track options with defaults
91
- const trackImpression = track?.impression !== false;
92
- const trackClick = track?.primaryClick !== false;
93
- const impressionEvent = track?.impressionEventName ?? "$experiment_exposure";
94
- const clickEvent = track?.clickEventName ?? "$experiment_click";
95
-
96
- // ── Assignment resolution ──────────────────────────────────────────────
97
-
98
- const [variantKey, setVariantKey] = useState<string>(() => {
99
- // Defer localStorage read to useEffect to avoid hydration mismatch.
100
- if (bootstrap[id]) return bootstrap[id];
101
- return "control";
102
- });
103
- const [resolved, setResolved] = useState<boolean>(() => {
104
- return !!bootstrap[id];
105
- });
106
-
107
- useEffect(() => {
108
- // Already resolved from bootstrap or cache
109
- if (bootstrap[id] || readAssignment(id)) {
110
- // Ensure state is synced (StrictMode may re-mount)
111
- const key = bootstrap[id] ?? readAssignment(id) ?? "control";
112
- setVariantKey(key);
113
- setResolved(true);
114
- return;
115
- }
116
-
117
- let cancelled = false;
118
-
119
- (async () => {
120
- try {
121
- const distinctId = customerId ?? getDistinctId();
122
- const key = await fetchDecision(host, id, distinctId);
123
- if (cancelled) return;
124
-
125
- // Validate variant key
126
- if (key !== "control" && !(key in variants)) {
127
- if (debug) {
128
- console.warn(
129
- `[probat] Unknown variant "${key}" for experiment "${id}", falling back to control`
130
- );
131
- }
132
- setVariantKey("control");
133
- } else {
134
- setVariantKey(key);
135
- writeAssignment(id, key);
136
- }
137
- } catch (err) {
138
- if (cancelled) return;
139
- if (debug) {
140
- console.error(`[probat] fetchDecision failed for "${id}":`, err);
141
- }
142
- if (fallback === "suspend") throw err;
143
- setVariantKey("control");
144
- } finally {
145
- if (!cancelled) setResolved(true);
146
- }
147
- })();
148
-
149
- return () => {
150
- cancelled = true;
151
- };
152
- }, [id, host]); // eslint-disable-line react-hooks/exhaustive-deps
46
+ // ── Assignment (decoupled) ──────────────────────────────────────────────
153
47
 
154
- // ── Debug logging ──────────────────────────────────────────────────────
48
+ const { variantKey: rawKey, resolved } = useExperiment(id, { fallback, debug });
155
49
 
156
- useEffect(() => {
157
- if (debug && resolved) {
158
- console.log(`[probat] Experiment "${id}" variant "${variantKey}"`, {
159
- instanceId,
160
- pageKey: getPageKey(),
161
- });
162
- }
163
- }, [debug, id, variantKey, resolved, instanceId]);
50
+ // Validate variant key against the ReactNode map
51
+ const variantKey =
52
+ rawKey === "control" || rawKey in variants ? rawKey : "control";
164
53
 
165
- // ── Shared event properties ────────────────────────────────────────────
166
-
167
- const eventProps = useMemo(
168
- () => ({
169
- experiment_id: id,
170
- variant_key: variantKey,
171
- component_instance_id: instanceId,
172
- ...(customerId ? { customer_id: customerId } : {}),
173
- }),
174
- [id, variantKey, instanceId, customerId]
175
- );
176
-
177
- // ── Impression tracking via IntersectionObserver ────────────────────────
178
-
179
- const containerRef = useRef<HTMLDivElement>(null);
180
- const impressionSent = useRef(false);
181
-
182
- useEffect(() => {
183
- if (!trackImpression || !resolved) return;
184
-
185
- // Reset on re-mount (StrictMode safety)
186
- impressionSent.current = false;
187
-
188
- const pageKey = getPageKey();
189
- const dedupeKey = makeDedupeKey(id, variantKey, instanceId, pageKey);
190
-
191
- // Already seen this session
192
- if (hasSeen(dedupeKey)) {
193
- impressionSent.current = true;
194
- return;
195
- }
196
-
197
- const el = containerRef.current;
198
- if (!el) return;
199
-
200
- // Fallback: no IntersectionObserver (SSR, old browser)
201
- if (typeof IntersectionObserver === "undefined") {
202
- if (!impressionSent.current) {
203
- impressionSent.current = true;
204
- markSeen(dedupeKey);
205
- sendMetric(host, impressionEvent, eventProps);
206
- if (debug) console.log(`[probat] Impression sent (no IO) for "${id}"`);
207
- }
208
- return;
209
- }
210
-
211
- let timer: ReturnType<typeof setTimeout> | null = null;
212
-
213
- const observer = new IntersectionObserver(
214
- ([entry]) => {
215
- if (!entry || impressionSent.current) return;
216
-
217
- if (entry.isIntersecting) {
218
- timer = setTimeout(() => {
219
- if (impressionSent.current) return;
220
- impressionSent.current = true;
221
- markSeen(dedupeKey);
222
- sendMetric(host, impressionEvent, eventProps);
223
- if (debug) console.log(`[probat] Impression sent for "${id}"`);
224
- observer.disconnect();
225
- }, 250);
226
- } else if (timer) {
227
- clearTimeout(timer);
228
- timer = null;
229
- }
230
- },
231
- { threshold: 0.5 }
54
+ if (debug && rawKey !== variantKey) {
55
+ console.warn(
56
+ `[probat] Unknown variant "${rawKey}" for experiment "${id}", falling back to control`
232
57
  );
58
+ }
233
59
 
234
- observer.observe(el);
60
+ // ── Tracking (decoupled) ────────────────────────────────────────────────
235
61
 
236
- return () => {
237
- observer.disconnect();
238
- if (timer) clearTimeout(timer);
239
- };
240
- }, [
241
- trackImpression,
242
- resolved,
243
- id,
62
+ const trackRef = useTrack({
63
+ experimentId: id,
244
64
  variantKey,
245
- instanceId,
246
- host,
247
- impressionEvent,
248
- eventProps,
65
+ componentInstanceId,
66
+ impression: resolved ? (track?.impression !== false) : false,
67
+ click: track?.primaryClick !== false,
68
+ impressionEventName: track?.impressionEventName,
69
+ clickEventName: track?.clickEventName,
249
70
  debug,
250
- ]);
251
-
252
- // ── Click tracking ─────────────────────────────────────────────────────
253
-
254
- const handleClick = useCallback(
255
- (e: React.MouseEvent) => {
256
- if (!trackClick) return;
257
-
258
- const meta = extractClickMeta(e.target as EventTarget);
259
- if (!meta) return;
260
-
261
- sendMetric(host, clickEvent, {
262
- ...eventProps,
263
- ...meta,
264
- });
265
- if (debug) {
266
- console.log(`[probat] Click tracked for "${id}"`, meta);
267
- }
268
- },
269
- [trackClick, host, clickEvent, eventProps, id, debug]
270
- );
71
+ });
271
72
 
272
73
  // ── Render ─────────────────────────────────────────────────────────────
273
74
 
@@ -278,8 +79,7 @@ export function Experiment({
278
79
 
279
80
  return (
280
81
  <div
281
- ref={containerRef}
282
- onClick={handleClick}
82
+ ref={trackRef as React.RefObject<HTMLDivElement>}
283
83
  data-probat-experiment={id}
284
84
  data-probat-variant={variantKey}
285
85
  style={{
@@ -0,0 +1,34 @@
1
+ "use client";
2
+
3
+ import React from "react";
4
+ import { useTrack, type UseTrackOptions } from "../hooks/useTrack";
5
+
6
+ export interface TrackProps extends UseTrackOptions {
7
+ children: React.ReactNode;
8
+ }
9
+
10
+ /**
11
+ * Wrapper component that attaches impression and click tracking to its children.
12
+ * Alternative to the `useTrack` hook when you prefer a component over a ref.
13
+ *
14
+ * @example
15
+ * ```tsx
16
+ * <Track experimentId="pricing" variantKey="ai_v1">
17
+ * <PricingCard />
18
+ * </Track>
19
+ * ```
20
+ */
21
+ export function Track({ children, ...trackOptions }: TrackProps) {
22
+ const trackRef = useTrack(trackOptions);
23
+
24
+ return (
25
+ <div
26
+ ref={trackRef as React.RefObject<HTMLDivElement>}
27
+ data-probat-track={trackOptions.experimentId}
28
+ data-probat-variant={trackOptions.variantKey}
29
+ style={{ display: "contents" }}
30
+ >
31
+ {children}
32
+ </div>
33
+ );
34
+ }
@@ -0,0 +1,118 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { useProbatContext } from "../context/ProbatContext";
5
+ import { fetchDecision } from "../utils/api";
6
+ import { getDistinctId } from "../utils/eventContext";
7
+
8
+ // ── localStorage assignment cache ──────────────────────────────────────────
9
+
10
+ const ASSIGNMENT_PREFIX = "probat:assignment:";
11
+
12
+ interface StoredAssignment {
13
+ variantKey: string;
14
+ ts: number;
15
+ }
16
+
17
+ export function readAssignment(id: string): string | null {
18
+ if (typeof window === "undefined") return null;
19
+ try {
20
+ const raw = localStorage.getItem(ASSIGNMENT_PREFIX + id);
21
+ if (!raw) return null;
22
+ const parsed: StoredAssignment = JSON.parse(raw);
23
+ return parsed.variantKey ?? null;
24
+ } catch {
25
+ return null;
26
+ }
27
+ }
28
+
29
+ export function writeAssignment(id: string, variantKey: string): void {
30
+ if (typeof window === "undefined") return;
31
+ try {
32
+ const entry: StoredAssignment = { variantKey, ts: Date.now() };
33
+ localStorage.setItem(ASSIGNMENT_PREFIX + id, JSON.stringify(entry));
34
+ } catch {}
35
+ }
36
+
37
+ // ── Types ──────────────────────────────────────────────────────────────────
38
+
39
+ export interface UseExperimentOptions {
40
+ /** Behavior when assignment fetch fails: "control" (default) renders control, "suspend" throws */
41
+ fallback?: "control" | "suspend";
42
+ /** Log decisions to console */
43
+ debug?: boolean;
44
+ }
45
+
46
+ export interface UseExperimentReturn {
47
+ /** The resolved variant key (e.g. "control", "ai_v1") */
48
+ variantKey: string;
49
+ /** Whether the assignment has been resolved (bootstrap, cache, or fetch) */
50
+ resolved: boolean;
51
+ }
52
+
53
+ // ── Hook ───────────────────────────────────────────────────────────────────
54
+
55
+ /**
56
+ * Resolves the variant assignment for an experiment.
57
+ * No tracking — use `useTrack` or `<Track>` to observe impressions/clicks.
58
+ *
59
+ * Priority: bootstrap > localStorage cache > fetchDecision.
60
+ */
61
+ export function useExperiment(
62
+ id: string,
63
+ options: UseExperimentOptions = {}
64
+ ): UseExperimentReturn {
65
+ const { fallback = "control", debug = false } = options;
66
+ const { host, bootstrap, customerId } = useProbatContext();
67
+
68
+ const [variantKey, setVariantKey] = useState<string>(() => {
69
+ if (bootstrap[id]) return bootstrap[id];
70
+ return "control";
71
+ });
72
+ const [resolved, setResolved] = useState<boolean>(() => {
73
+ return !!bootstrap[id];
74
+ });
75
+
76
+ useEffect(() => {
77
+ if (bootstrap[id] || readAssignment(id)) {
78
+ const key = bootstrap[id] ?? readAssignment(id) ?? "control";
79
+ setVariantKey(key);
80
+ setResolved(true);
81
+ return;
82
+ }
83
+
84
+ let cancelled = false;
85
+
86
+ (async () => {
87
+ try {
88
+ const distinctId = customerId ?? getDistinctId();
89
+ const key = await fetchDecision(host, id, distinctId);
90
+ if (cancelled) return;
91
+
92
+ setVariantKey(key);
93
+ writeAssignment(id, key);
94
+ } catch (err) {
95
+ if (cancelled) return;
96
+ if (debug) {
97
+ console.error(`[probat] fetchDecision failed for "${id}":`, err);
98
+ }
99
+ if (fallback === "suspend") throw err;
100
+ setVariantKey("control");
101
+ } finally {
102
+ if (!cancelled) setResolved(true);
103
+ }
104
+ })();
105
+
106
+ return () => {
107
+ cancelled = true;
108
+ };
109
+ }, [id, host]); // eslint-disable-line react-hooks/exhaustive-deps
110
+
111
+ useEffect(() => {
112
+ if (debug && resolved) {
113
+ console.log(`[probat] Experiment "${id}" -> variant "${variantKey}"`);
114
+ }
115
+ }, [debug, id, variantKey, resolved]);
116
+
117
+ return { variantKey, resolved };
118
+ }
@@ -28,7 +28,7 @@ export function useProbatMetrics(): UseProbatMetricsReturn {
28
28
  const capture = useCallback(
29
29
  (event: string, properties: Record<string, unknown> = {}) => {
30
30
  sendMetric(host, event, {
31
- ...(customerId ? { customer_id: customerId } : {}),
31
+ ...(customerId ? { distinct_id: customerId } : {}),
32
32
  ...properties,
33
33
  });
34
34
  },
@@ -0,0 +1,173 @@
1
+ "use client";
2
+
3
+ import { useEffect, useRef, useMemo, useCallback } from "react";
4
+ import { useProbatContext } from "../context/ProbatContext";
5
+ import { sendMetric, extractClickMeta } from "../utils/api";
6
+ import { getPageKey } from "../utils/eventContext";
7
+ import { makeDedupeKey, hasSeen, markSeen } from "../utils/dedupeStorage";
8
+ import { useStableInstanceId } from "../utils/stableInstanceId";
9
+
10
+ // ── Types ──────────────────────────────────────────────────────────────────
11
+
12
+ export interface UseTrackOptions {
13
+ /** Experiment identifier */
14
+ experimentId: string;
15
+ /** The variant key to attach to events */
16
+ variantKey: string;
17
+ /** Stable instance id when multiple instances of the same experiment exist on a page */
18
+ componentInstanceId?: string;
19
+ /** Auto-track impressions (default true) */
20
+ impression?: boolean;
21
+ /** Auto-track clicks (default true) */
22
+ click?: boolean;
23
+ /** Custom impression event name (default "$experiment_exposure") */
24
+ impressionEventName?: string;
25
+ /** Custom click event name (default "$experiment_click") */
26
+ clickEventName?: string;
27
+ /** Log events to console */
28
+ debug?: boolean;
29
+ }
30
+
31
+ // ── Hook ───────────────────────────────────────────────────────────────────
32
+
33
+ /**
34
+ * Attaches impression and click tracking to a DOM element via a ref.
35
+ * Completely independent of variant assignment — pass the variantKey explicitly.
36
+ *
37
+ * @example
38
+ * ```tsx
39
+ * const trackRef = useTrack({ experimentId: "pricing", variantKey: "ai_v1" });
40
+ * return <div ref={trackRef}>...</div>;
41
+ * ```
42
+ */
43
+ export function useTrack(options: UseTrackOptions): React.RefObject<HTMLElement | null> {
44
+ const {
45
+ experimentId,
46
+ variantKey,
47
+ componentInstanceId,
48
+ impression: trackImpression = true,
49
+ click: trackClick = true,
50
+ impressionEventName = "$experiment_exposure",
51
+ clickEventName = "$experiment_click",
52
+ debug = false,
53
+ } = options;
54
+
55
+ const { host, customerId } = useProbatContext();
56
+
57
+ const autoInstanceId = useStableInstanceId(experimentId);
58
+ const instanceId = componentInstanceId ?? autoInstanceId;
59
+
60
+ const containerRef = useRef<HTMLElement | null>(null);
61
+ const impressionSent = useRef(false);
62
+
63
+ const eventProps = useMemo(
64
+ () => ({
65
+ experiment_id: experimentId,
66
+ variant_key: variantKey,
67
+ component_instance_id: instanceId,
68
+ ...(customerId ? { distinct_id: customerId } : {}),
69
+ }),
70
+ [experimentId, variantKey, instanceId, customerId]
71
+ );
72
+
73
+ // ── Impression tracking via IntersectionObserver ────────────────────────
74
+
75
+ useEffect(() => {
76
+ if (!trackImpression) return;
77
+
78
+ impressionSent.current = false;
79
+
80
+ const pageKey = getPageKey();
81
+ const dedupeKey = makeDedupeKey(experimentId, variantKey, instanceId, pageKey);
82
+
83
+ if (hasSeen(dedupeKey)) {
84
+ impressionSent.current = true;
85
+ return;
86
+ }
87
+
88
+ const el = containerRef.current;
89
+ if (!el) return;
90
+
91
+ // Fallback: no IntersectionObserver (SSR, old browser)
92
+ if (typeof IntersectionObserver === "undefined") {
93
+ if (!impressionSent.current) {
94
+ impressionSent.current = true;
95
+ markSeen(dedupeKey);
96
+ sendMetric(host, impressionEventName, eventProps);
97
+ if (debug) console.log(`[probat] Impression sent (no IO) for "${experimentId}"`);
98
+ }
99
+ return;
100
+ }
101
+
102
+ let timer: ReturnType<typeof setTimeout> | null = null;
103
+
104
+ const observer = new IntersectionObserver(
105
+ ([entry]) => {
106
+ if (!entry || impressionSent.current) return;
107
+
108
+ if (entry.isIntersecting) {
109
+ timer = setTimeout(() => {
110
+ if (impressionSent.current) return;
111
+ impressionSent.current = true;
112
+ markSeen(dedupeKey);
113
+ sendMetric(host, impressionEventName, eventProps);
114
+ if (debug) console.log(`[probat] Impression sent for "${experimentId}"`);
115
+ observer.disconnect();
116
+ }, 250);
117
+ } else if (timer) {
118
+ clearTimeout(timer);
119
+ timer = null;
120
+ }
121
+ },
122
+ { threshold: 0.5 }
123
+ );
124
+
125
+ observer.observe(el);
126
+
127
+ return () => {
128
+ observer.disconnect();
129
+ if (timer) clearTimeout(timer);
130
+ };
131
+ }, [
132
+ trackImpression,
133
+ experimentId,
134
+ variantKey,
135
+ instanceId,
136
+ host,
137
+ impressionEventName,
138
+ eventProps,
139
+ debug,
140
+ ]);
141
+
142
+ // ── Click tracking ─────────────────────────────────────────────────────
143
+
144
+ const handleClick = useCallback(
145
+ (e: Event) => {
146
+ if (!trackClick) return;
147
+
148
+ const meta = extractClickMeta(e.target as EventTarget);
149
+ if (!meta) return;
150
+
151
+ sendMetric(host, clickEventName, {
152
+ ...eventProps,
153
+ ...meta,
154
+ });
155
+ if (debug) {
156
+ console.log(`[probat] Click tracked for "${experimentId}"`, meta);
157
+ }
158
+ },
159
+ [trackClick, host, clickEventName, eventProps, experimentId, debug]
160
+ );
161
+
162
+ useEffect(() => {
163
+ const el = containerRef.current;
164
+ if (!el || !trackClick) return;
165
+
166
+ el.addEventListener("click", handleClick);
167
+ return () => {
168
+ el.removeEventListener("click", handleClick);
169
+ };
170
+ }, [handleClick, trackClick]);
171
+
172
+ return containerRef;
173
+ }
package/src/index.ts CHANGED
@@ -8,10 +8,19 @@ export type { ProbatProviderProps } from "./context/ProbatContext";
8
8
  export { Experiment } from "./components/Experiment";
9
9
  export type { ExperimentProps, ExperimentTrackOptions } from "./components/Experiment";
10
10
 
11
+ // ── Track component ────────────────────────────────────────────────────────
12
+ export { Track } from "./components/Track";
13
+ export type { TrackProps } from "./components/Track";
14
+
11
15
  // ── Hooks ──────────────────────────────────────────────────────────────────
16
+ export { useExperiment } from "./hooks/useExperiment";
17
+ export type { UseExperimentOptions, UseExperimentReturn } from "./hooks/useExperiment";
18
+ export { useTrack } from "./hooks/useTrack";
19
+ export type { UseTrackOptions } from "./hooks/useTrack";
12
20
  export { useProbatMetrics } from "./hooks/useProbatMetrics";
13
21
  export type { UseProbatMetricsReturn } from "./hooks/useProbatMetrics";
14
22
 
15
23
  // ── Utilities (advanced) ───────────────────────────────────────────────────
16
24
  export { sendMetric, fetchDecision } from "./utils/api";
17
25
  export type { MetricPayload, DecisionResponse } from "./utils/api";
26
+ export { createExperimentContext } from "./utils/createExperimentContext";
@@ -0,0 +1,47 @@
1
+ "use client";
2
+
3
+ import React, { createContext, useContext, type ReactNode } from "react";
4
+
5
+ /**
6
+ * Factory that creates a typed context + hook pair for passing a variantKey
7
+ * between components in different files without prop-drilling.
8
+ *
9
+ * @example
10
+ * ```tsx
11
+ * // experiment-context.ts
12
+ * export const { ExperimentProvider, useVariantKey } = createExperimentContext("pricing-test");
13
+ *
14
+ * // Parent.tsx
15
+ * const { variantKey } = useExperiment("pricing-test");
16
+ * <ExperimentProvider value={variantKey}><Child /></ExperimentProvider>
17
+ *
18
+ * // Child.tsx (different file)
19
+ * const variantKey = useVariantKey();
20
+ * const trackRef = useTrack({ experimentId: "pricing-test", variantKey });
21
+ * ```
22
+ */
23
+ export function createExperimentContext(experimentId: string) {
24
+ const Ctx = createContext<string | null>(null);
25
+
26
+ function ExperimentProvider({
27
+ value,
28
+ children,
29
+ }: {
30
+ value: string;
31
+ children: ReactNode;
32
+ }) {
33
+ return React.createElement(Ctx.Provider, { value }, children);
34
+ }
35
+
36
+ function useVariantKey(): string {
37
+ const v = useContext(Ctx);
38
+ if (v === null) {
39
+ throw new Error(
40
+ `useVariantKey() must be used inside <ExperimentProvider> for "${experimentId}"`
41
+ );
42
+ }
43
+ return v;
44
+ }
45
+
46
+ return { ExperimentProvider, useVariantKey };
47
+ }