@probat/react 0.4.1 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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 ? { distinct_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 type TrackProps = 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 ?? "server-resolved"}
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
+ }
@@ -0,0 +1,213 @@
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
+ interface UseTrackBaseOptions {
13
+ /** Experiment identifier */
14
+ experimentId: string;
15
+ /** Stable instance id when multiple instances of the same experiment exist on a page */
16
+ componentInstanceId?: string;
17
+ /** Auto-track impressions (default true) */
18
+ impression?: boolean;
19
+ /** Auto-track clicks (default true) */
20
+ click?: boolean;
21
+ /** Custom impression event name (default "$experiment_exposure") */
22
+ impressionEventName?: string;
23
+ /** Custom click event name (default "$experiment_click") */
24
+ clickEventName?: string;
25
+ /** Log events to console */
26
+ debug?: boolean;
27
+ }
28
+
29
+ /** Explicit mode: pass the variant key directly */
30
+ export interface UseTrackExplicitOptions extends UseTrackBaseOptions {
31
+ /** The variant key to attach to events */
32
+ variantKey: string;
33
+ customerId?: undefined;
34
+ }
35
+
36
+ /** Customer mode: backend resolves variant from assignment table */
37
+ export interface UseTrackCustomerOptions extends UseTrackBaseOptions {
38
+ variantKey?: undefined;
39
+ /** Customer ID to resolve variant server-side. Falls back to provider's customerId. */
40
+ customerId?: string;
41
+ }
42
+
43
+ export type UseTrackOptions = UseTrackExplicitOptions | UseTrackCustomerOptions;
44
+
45
+ // ── Hook ───────────────────────────────────────────────────────────────────
46
+
47
+ /**
48
+ * Attaches impression and click tracking to a DOM element via a ref.
49
+ *
50
+ * Two modes:
51
+ * - **Explicit**: pass `variantKey` directly — stamped on every event.
52
+ * - **Customer**: omit `variantKey` — backend resolves it from the assignment table
53
+ * using `(experimentId, customerId)`.
54
+ *
55
+ * @example
56
+ * ```tsx
57
+ * // Explicit mode
58
+ * const trackRef = useTrack({ experimentId: "pricing", variantKey: "ai_v1" });
59
+ *
60
+ * // Customer mode (backend resolves variant)
61
+ * const trackRef = useTrack({ experimentId: "pricing", customerId: "user_123" });
62
+ * ```
63
+ */
64
+ export function useTrack(options: UseTrackOptions): React.RefObject<HTMLElement | null> {
65
+ const {
66
+ experimentId,
67
+ componentInstanceId,
68
+ impression: trackImpression = true,
69
+ click: trackClick = true,
70
+ impressionEventName = "$experiment_exposure",
71
+ clickEventName = "$experiment_click",
72
+ debug = false,
73
+ } = options;
74
+
75
+ const variantKey = options.variantKey ?? undefined;
76
+ const explicitCustomerId = "customerId" in options ? options.customerId : undefined;
77
+
78
+ const { host, customerId: providerCustomerId } = useProbatContext();
79
+
80
+ // In customer mode, use explicit customerId or fall back to provider's
81
+ const resolvedCustomerId = explicitCustomerId ?? providerCustomerId;
82
+ const isCustomerMode = !variantKey;
83
+
84
+ const autoInstanceId = useStableInstanceId(experimentId);
85
+ const instanceId = componentInstanceId ?? autoInstanceId;
86
+
87
+ const containerRef = useRef<HTMLElement | null>(null);
88
+ const impressionSent = useRef(false);
89
+
90
+ // Runtime warning
91
+ useEffect(() => {
92
+ if (isCustomerMode && !resolvedCustomerId && debug) {
93
+ console.warn(
94
+ `[probat] useTrack called without variantKey and no customerId ` +
95
+ `available for "${experimentId}". Events will have no variant attribution.`
96
+ );
97
+ }
98
+ }, [isCustomerMode, resolvedCustomerId, experimentId, debug]);
99
+
100
+ const eventProps = useMemo(
101
+ () => ({
102
+ experiment_id: experimentId,
103
+ ...(variantKey ? { variant_key: variantKey } : {}),
104
+ component_instance_id: instanceId,
105
+ ...(resolvedCustomerId ? { distinct_id: resolvedCustomerId } : {}),
106
+ }),
107
+ [experimentId, variantKey, instanceId, resolvedCustomerId]
108
+ );
109
+
110
+ // ── Impression tracking via IntersectionObserver ────────────────────────
111
+
112
+ // In customer mode, use customerId for dedupe instead of variantKey
113
+ const dedupeVariant = variantKey ?? resolvedCustomerId ?? "__anon__";
114
+
115
+ useEffect(() => {
116
+ if (!trackImpression) return;
117
+
118
+ impressionSent.current = false;
119
+
120
+ const pageKey = getPageKey();
121
+ const dedupeKey = makeDedupeKey(experimentId, dedupeVariant, instanceId, pageKey);
122
+
123
+ if (hasSeen(dedupeKey)) {
124
+ impressionSent.current = true;
125
+ return;
126
+ }
127
+
128
+ const el = containerRef.current;
129
+ if (!el) return;
130
+
131
+ // Fallback: no IntersectionObserver (SSR, old browser)
132
+ if (typeof IntersectionObserver === "undefined") {
133
+ if (!impressionSent.current) {
134
+ impressionSent.current = true;
135
+ markSeen(dedupeKey);
136
+ sendMetric(host, impressionEventName, eventProps);
137
+ if (debug) console.log(`[probat] Impression sent (no IO) for "${experimentId}"`);
138
+ }
139
+ return;
140
+ }
141
+
142
+ let timer: ReturnType<typeof setTimeout> | null = null;
143
+
144
+ const observer = new IntersectionObserver(
145
+ ([entry]) => {
146
+ if (!entry || impressionSent.current) return;
147
+
148
+ if (entry.isIntersecting) {
149
+ timer = setTimeout(() => {
150
+ if (impressionSent.current) return;
151
+ impressionSent.current = true;
152
+ markSeen(dedupeKey);
153
+ sendMetric(host, impressionEventName, eventProps);
154
+ if (debug) console.log(`[probat] Impression sent for "${experimentId}"`);
155
+ observer.disconnect();
156
+ }, 250);
157
+ } else if (timer) {
158
+ clearTimeout(timer);
159
+ timer = null;
160
+ }
161
+ },
162
+ { threshold: 0.5 }
163
+ );
164
+
165
+ observer.observe(el);
166
+
167
+ return () => {
168
+ observer.disconnect();
169
+ if (timer) clearTimeout(timer);
170
+ };
171
+ }, [
172
+ trackImpression,
173
+ experimentId,
174
+ dedupeVariant,
175
+ instanceId,
176
+ host,
177
+ impressionEventName,
178
+ eventProps,
179
+ debug,
180
+ ]);
181
+
182
+ // ── Click tracking ─────────────────────────────────────────────────────
183
+
184
+ const handleClick = useCallback(
185
+ (e: Event) => {
186
+ if (!trackClick) return;
187
+
188
+ const meta = extractClickMeta(e.target as EventTarget);
189
+ if (!meta) return;
190
+
191
+ sendMetric(host, clickEventName, {
192
+ ...eventProps,
193
+ ...meta,
194
+ });
195
+ if (debug) {
196
+ console.log(`[probat] Click tracked for "${experimentId}"`, meta);
197
+ }
198
+ },
199
+ [trackClick, host, clickEventName, eventProps, experimentId, debug]
200
+ );
201
+
202
+ useEffect(() => {
203
+ const el = containerRef.current;
204
+ if (!el || !trackClick) return;
205
+
206
+ el.addEventListener("click", handleClick);
207
+ return () => {
208
+ el.removeEventListener("click", handleClick);
209
+ };
210
+ }, [handleClick, trackClick]);
211
+
212
+ return containerRef;
213
+ }
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, UseTrackExplicitOptions, UseTrackCustomerOptions } 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";