@probat/react 0.2.1 → 0.3.1

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.
@@ -0,0 +1,40 @@
1
+ /**
2
+ * Dedupe storage for experiment exposures.
3
+ * Uses sessionStorage + in-memory Set fallback.
4
+ * Key format: probat:seen:{id}:{variantKey}:{instanceId}:{pageKey}
5
+ */
6
+
7
+ const PREFIX = "probat:seen:";
8
+ const memorySet = new Set<string>();
9
+
10
+ export function makeDedupeKey(
11
+ experimentId: string,
12
+ variantKey: string,
13
+ instanceId: string,
14
+ pageKey: string
15
+ ): string {
16
+ return `${PREFIX}${experimentId}:${variantKey}:${instanceId}:${pageKey}`;
17
+ }
18
+
19
+ export function hasSeen(key: string): boolean {
20
+ if (memorySet.has(key)) return true;
21
+ if (typeof window === "undefined") return false;
22
+ try {
23
+ return sessionStorage.getItem(key) === "1";
24
+ } catch {
25
+ return false;
26
+ }
27
+ }
28
+
29
+ export function markSeen(key: string): void {
30
+ memorySet.add(key);
31
+ if (typeof window === "undefined") return;
32
+ try {
33
+ sessionStorage.setItem(key, "1");
34
+ } catch {}
35
+ }
36
+
37
+ /** Reset all dedupe state — useful for testing. */
38
+ export function resetDedupe(): void {
39
+ memorySet.clear();
40
+ }
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Event context helpers: distinct_id, session_id, page info.
3
+ * All browser-safe — no-ops when window is unavailable.
4
+ */
5
+
6
+ const DISTINCT_ID_KEY = "probat:distinct_id";
7
+ const SESSION_ID_KEY = "probat:session_id";
8
+
9
+ let cachedDistinctId: string | null = null;
10
+ let cachedSessionId: string | null = null;
11
+
12
+ function generateId(): string {
13
+ // crypto.randomUUID where available, else fallback
14
+ if (typeof crypto !== "undefined" && crypto.randomUUID) {
15
+ return crypto.randomUUID();
16
+ }
17
+ // fallback: random hex
18
+ const bytes = new Uint8Array(16);
19
+ if (typeof crypto !== "undefined" && crypto.getRandomValues) {
20
+ crypto.getRandomValues(bytes);
21
+ } else {
22
+ for (let i = 0; i < 16; i++) bytes[i] = Math.floor(Math.random() * 256);
23
+ }
24
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
25
+ }
26
+
27
+ export function getDistinctId(): string {
28
+ if (cachedDistinctId) return cachedDistinctId;
29
+ if (typeof window === "undefined") return "server";
30
+ try {
31
+ const stored = localStorage.getItem(DISTINCT_ID_KEY);
32
+ if (stored) {
33
+ cachedDistinctId = stored;
34
+ return stored;
35
+ }
36
+ } catch {}
37
+ const id = `anon_${generateId()}`;
38
+ cachedDistinctId = id;
39
+ try {
40
+ localStorage.setItem(DISTINCT_ID_KEY, id);
41
+ } catch {}
42
+ return id;
43
+ }
44
+
45
+ export function getSessionId(): string {
46
+ if (cachedSessionId) return cachedSessionId;
47
+ if (typeof window === "undefined") return "server";
48
+ try {
49
+ const stored = sessionStorage.getItem(SESSION_ID_KEY);
50
+ if (stored) {
51
+ cachedSessionId = stored;
52
+ return stored;
53
+ }
54
+ } catch {}
55
+ const id = `sess_${generateId()}`;
56
+ cachedSessionId = id;
57
+ try {
58
+ sessionStorage.setItem(SESSION_ID_KEY, id);
59
+ } catch {}
60
+ return id;
61
+ }
62
+
63
+ export function getPageKey(): string {
64
+ if (typeof window === "undefined") return "";
65
+ return window.location.pathname + window.location.search;
66
+ }
67
+
68
+ export function getPageUrl(): string {
69
+ if (typeof window === "undefined") return "";
70
+ return window.location.href;
71
+ }
72
+
73
+ export function getReferrer(): string {
74
+ if (typeof document === "undefined") return "";
75
+ return document.referrer;
76
+ }
77
+
78
+ export interface EventContext {
79
+ distinct_id: string;
80
+ session_id: string;
81
+ $page_url: string;
82
+ $pathname: string;
83
+ $referrer: string;
84
+ }
85
+
86
+ export function buildEventContext(): EventContext {
87
+ return {
88
+ distinct_id: getDistinctId(),
89
+ session_id: getSessionId(),
90
+ $page_url: getPageUrl(),
91
+ $pathname: typeof window !== "undefined" ? window.location.pathname : "",
92
+ $referrer: getReferrer(),
93
+ };
94
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Stable auto-generated instance IDs for <Experiment />.
3
+ *
4
+ * Problem: a naïve useRef + module counter gives a different ID on every mount,
5
+ * so StrictMode double-mount or unmount/remount changes the dedupe key.
6
+ *
7
+ * Solution:
8
+ * 1. React 18+ → useId() is stable per fiber position.
9
+ * 2. Fallback → sessionStorage-backed slot counter per (experimentId, pageKey).
10
+ * 3. Both paths persist a mapping in sessionStorage:
11
+ * probat:instance:{experimentId}:{pageKey}:{positionKey} → stableId
12
+ * so the same position resolves to the same ID across mounts.
13
+ */
14
+
15
+ import React, { useRef } from "react";
16
+ import { getPageKey } from "./eventContext";
17
+
18
+ const INSTANCE_PREFIX = "probat:instance:";
19
+
20
+ // ── Helpers ────────────────────────────────────────────────────────────────
21
+
22
+ function shortId(): string {
23
+ const bytes = new Uint8Array(4);
24
+ if (typeof crypto !== "undefined" && crypto.getRandomValues) {
25
+ crypto.getRandomValues(bytes);
26
+ } else {
27
+ for (let i = 0; i < 4; i++) bytes[i] = Math.floor(Math.random() * 256);
28
+ }
29
+ return Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join("");
30
+ }
31
+
32
+ /**
33
+ * Look up or create a stable instance ID in sessionStorage.
34
+ */
35
+ function resolveStableId(storageKey: string): string {
36
+ if (typeof window !== "undefined") {
37
+ try {
38
+ const stored = sessionStorage.getItem(storageKey);
39
+ if (stored) return stored;
40
+ } catch {}
41
+ }
42
+ const id = `inst_${shortId()}`;
43
+ if (typeof window !== "undefined") {
44
+ try {
45
+ sessionStorage.setItem(storageKey, id);
46
+ } catch {}
47
+ }
48
+ return id;
49
+ }
50
+
51
+ // ── Fallback: render-wave slot counter ─────────────────────────────────────
52
+ // Each synchronous render batch claims sequential slots per (experimentId,
53
+ // pageKey). A microtask resets the counters so the next batch starts at 0,
54
+ // giving the same component position the same slot across mounts.
55
+
56
+ const slotCounters = new Map<string, number>();
57
+ let resetScheduled = false;
58
+
59
+ function claimSlot(groupKey: string): number {
60
+ const idx = slotCounters.get(groupKey) ?? 0;
61
+ slotCounters.set(groupKey, idx + 1);
62
+ if (!resetScheduled) {
63
+ resetScheduled = true;
64
+ Promise.resolve().then(() => {
65
+ slotCounters.clear();
66
+ resetScheduled = false;
67
+ });
68
+ }
69
+ return idx;
70
+ }
71
+
72
+ // ── Hook: React 18+ path (useId available) ─────────────────────────────────
73
+
74
+ function useStableInstanceIdV18(experimentId: string): string {
75
+ const reactId = (React as any).useId() as string;
76
+ const ref = useRef("");
77
+ if (!ref.current) {
78
+ const key = `${INSTANCE_PREFIX}${experimentId}:${getPageKey()}:${reactId}`;
79
+ ref.current = resolveStableId(key);
80
+ }
81
+ return ref.current;
82
+ }
83
+
84
+ // ── Hook: fallback path (no useId) ─────────────────────────────────────────
85
+
86
+ function useStableInstanceIdFallback(experimentId: string): string {
87
+ const slotRef = useRef(-1);
88
+ const ref = useRef("");
89
+ if (slotRef.current === -1) {
90
+ slotRef.current = claimSlot(`${experimentId}:${getPageKey()}`);
91
+ }
92
+ if (!ref.current) {
93
+ const key = `${INSTANCE_PREFIX}${experimentId}:${getPageKey()}:${slotRef.current}`;
94
+ ref.current = resolveStableId(key);
95
+ }
96
+ return ref.current;
97
+ }
98
+
99
+ // ── Exported hook ──────────────────────────────────────────────────────────
100
+ // Selection is a module-level constant so the hook-call count never changes
101
+ // between renders — safe for the rules of hooks.
102
+
103
+ export const useStableInstanceId: (experimentId: string) => string =
104
+ typeof (React as any).useId === "function"
105
+ ? useStableInstanceIdV18
106
+ : useStableInstanceIdFallback;
107
+
108
+ // ── Test utility ───────────────────────────────────────────────────────────
109
+
110
+ export function resetInstanceIdState(): void {
111
+ slotCounters.clear();
112
+ resetScheduled = false;
113
+ }
@@ -1,77 +1,35 @@
1
- const TTL_MS = 6 * 60 * 60 * 1000; // 6 hours
1
+ /**
2
+ * Safe localStorage/sessionStorage helpers.
3
+ */
2
4
 
3
- export type Choice = {
4
- experiment_id: string;
5
- label: string;
6
- ts: number;
7
- };
8
-
9
- export function safeGet(k: string): any | null {
5
+ export function safeGetLocal(key: string): string | null {
6
+ if (typeof window === "undefined") return null;
10
7
  try {
11
- const raw = localStorage.getItem(k);
12
- return raw ? JSON.parse(raw) : null;
8
+ return localStorage.getItem(key);
13
9
  } catch {
14
10
  return null;
15
11
  }
16
12
  }
17
13
 
18
- export function safeSet(k: string, v: any) {
14
+ export function safeSetLocal(key: string, value: string): void {
15
+ if (typeof window === "undefined") return;
19
16
  try {
20
- localStorage.setItem(k, JSON.stringify(v));
21
- } catch { }
22
- }
23
-
24
- export function now() {
25
- return Date.now();
17
+ localStorage.setItem(key, value);
18
+ } catch {}
26
19
  }
27
20
 
28
- export function fresh(ts: number) {
29
- return now() - ts <= TTL_MS;
30
- }
31
-
32
- export const KEY = (proposalId: string) => `probat_choice_v3:${proposalId}`;
33
- export const VISIT_KEY = (proposalId: string, label: string) =>
34
- `probat_visit_v1:${proposalId}:${label}`;
35
-
36
- export function readChoice(proposalId: string): Choice | null {
37
- const c = safeGet(KEY(proposalId)) as Choice | null;
38
- return c && fresh(c.ts) ? c : null;
39
- }
40
-
41
- export function writeChoice(
42
- proposalId: string,
43
- experiment_id: string,
44
- label: string
45
- ) {
46
- safeSet(KEY(proposalId), { experiment_id, label, ts: now() } as Choice);
47
- }
48
-
49
- const visitMemo = new Set<string>();
50
-
51
- export function hasTrackedVisit(proposalId: string, label: string): boolean {
52
- const key = VISIT_KEY(proposalId, label);
53
- if (visitMemo.has(key)) return true;
21
+ export function safeGetSession(key: string): string | null {
22
+ if (typeof window === "undefined") return null;
54
23
  try {
55
- const raw = localStorage.getItem(key);
56
- if (!raw) return false;
57
- const ts = Number(raw);
58
- if (!Number.isFinite(ts) || ts <= 0) return false;
59
- if (now() - ts > TTL_MS) {
60
- localStorage.removeItem(key);
61
- return false;
62
- }
63
- visitMemo.add(key);
64
- return true;
24
+ return sessionStorage.getItem(key);
65
25
  } catch {
66
- return false;
26
+ return null;
67
27
  }
68
28
  }
69
29
 
70
- export function markTrackedVisit(proposalId: string, label: string) {
71
- const key = VISIT_KEY(proposalId, label);
72
- visitMemo.add(key);
30
+ export function safeSetSession(key: string, value: string): void {
31
+ if (typeof window === "undefined") return;
73
32
  try {
74
- localStorage.setItem(key, now().toString());
75
- } catch { }
33
+ sessionStorage.setItem(key, value);
34
+ } catch {}
76
35
  }
77
-
@@ -1,10 +0,0 @@
1
- {
2
- "folders": [
3
- {
4
- "path": "../../../.."
5
- },
6
- {
7
- "path": "../../../../../itrt-frontend"
8
- }
9
- ]
10
- }
@@ -1,311 +0,0 @@
1
- "use client";
2
-
3
- import React, { useState, useEffect, useCallback } from "react";
4
- import type { MouseEvent } from "react";
5
- import { useProbatContext } from "../context/ProbatContext";
6
- import {
7
- fetchDecision,
8
- fetchComponentExperimentConfig,
9
- loadVariantComponent,
10
- sendMetric,
11
- extractClickMeta,
12
- } from "../utils/api";
13
- import {
14
- readChoice,
15
- writeChoice,
16
- hasTrackedVisit,
17
- markTrackedVisit,
18
- } from "../utils/storage";
19
-
20
- // Support both old and new API for backward compatibility
21
- export interface WithExperimentOptions {
22
- // New API: component-based
23
- componentPath?: string;
24
- repoFullName?: string;
25
-
26
- // Old API: direct proposal/registry (for backward compatibility)
27
- proposalId?: string;
28
- registry?: Record<string, React.ComponentType<any>>;
29
- }
30
-
31
- /**
32
- * Higher-Order Component for wrapping components with experiment variants
33
- */
34
- export function withExperiment<P = any>(
35
- Control: React.ComponentType<P>,
36
- options: WithExperimentOptions
37
- ): React.ComponentType<P & { probat?: { trackClick: () => void } }> {
38
- // Validate inputs at HOC level (not in component)
39
- if (!Control) {
40
- console.error("[PROBAT] withExperiment: Control component is required");
41
- return ((props: P) => null) as any;
42
- }
43
-
44
- if (!options || typeof options !== 'object') {
45
- console.error("[PROBAT] withExperiment: options is required");
46
- return Control as any;
47
- }
48
-
49
- const useNewAPI = !!options.componentPath;
50
- const useOldAPI = !!(options.proposalId && options.registry);
51
-
52
- if (!useNewAPI && !useOldAPI) {
53
- console.warn("[PROBAT] withExperiment: Invalid config, returning Control");
54
- return Control as any;
55
- }
56
-
57
- const ControlComponent = Control;
58
-
59
- function Wrapped(props: P) {
60
- // ============================================================
61
- // ALL HOOKS MUST BE AT THE TOP - BEFORE ANY CONDITIONAL RETURNS
62
- // ============================================================
63
-
64
- // 1. Context hook - always called first
65
- const context = useProbatContext();
66
- const apiBaseUrl = context?.apiBaseUrl || "https://gushi.onrender.com";
67
- const contextRepoFullName = context?.repoFullName;
68
-
69
- // 2. State hooks - always called in same order
70
- const [config, setConfig] = useState<{
71
- proposalId: string;
72
- variants: Record<string, React.ComponentType<any>>;
73
- } | null>(null);
74
- const [configLoading, setConfigLoading] = useState(useNewAPI);
75
- const [choice, setChoice] = useState<{
76
- experiment_id: string;
77
- label: string;
78
- } | null>(null);
79
-
80
- // Derived values
81
- const repoFullName = options.repoFullName || contextRepoFullName;
82
- const proposalId = useNewAPI ? config?.proposalId : options.proposalId;
83
-
84
- // 3. Effect hooks - always called
85
- // Load component config (new API)
86
- useEffect(() => {
87
- if (!useNewAPI) return;
88
- if (!repoFullName) {
89
- console.warn("[PROBAT] componentPath provided but repoFullName not found");
90
- setConfigLoading(false);
91
- return;
92
- }
93
-
94
- let alive = true;
95
- setConfigLoading(true);
96
-
97
- (async () => {
98
- try {
99
- const componentConfig = await fetchComponentExperimentConfig(
100
- apiBaseUrl,
101
- repoFullName,
102
- options.componentPath!
103
- );
104
-
105
- if (!alive) return;
106
-
107
- if (!componentConfig) {
108
- setConfig(null);
109
- setConfigLoading(false);
110
- return;
111
- }
112
-
113
- const variantComponents: Record<string, React.ComponentType<any>> = {
114
- control: ControlComponent,
115
- };
116
-
117
- for (const [label, variantInfo] of Object.entries(componentConfig.variants)) {
118
- if (label === "control") continue;
119
- if (variantInfo?.file_path) {
120
- try {
121
- const VariantComp = await loadVariantComponent(
122
- apiBaseUrl,
123
- componentConfig.proposal_id,
124
- variantInfo.experiment_id,
125
- variantInfo.file_path,
126
- componentConfig.repo_full_name,
127
- componentConfig.base_ref
128
- );
129
- if (VariantComp && typeof VariantComp === 'function' && alive) {
130
- variantComponents[label] = VariantComp;
131
- }
132
- } catch (e) {
133
- console.warn(`[PROBAT] Failed to load variant ${label}:`, e);
134
- }
135
- }
136
- }
137
-
138
- if (alive) {
139
- setConfig({
140
- proposalId: componentConfig.proposal_id,
141
- variants: variantComponents,
142
- });
143
- setConfigLoading(false);
144
- }
145
- } catch (e) {
146
- console.warn("[PROBAT] Failed to load component config:", e);
147
- if (alive) {
148
- setConfig(null);
149
- setConfigLoading(false);
150
- }
151
- }
152
- })();
153
-
154
- return () => { alive = false; };
155
- }, [useNewAPI, options.componentPath, repoFullName, apiBaseUrl]);
156
-
157
- // Fetch experiment decision
158
- useEffect(() => {
159
- if (!proposalId) return;
160
- if (useNewAPI && configLoading) return;
161
-
162
- let alive = true;
163
-
164
- // Detect if we are in heatmap mode
165
- const isHeatmapMode = typeof window !== 'undefined' &&
166
- new URLSearchParams(window.location.search).get('heatmap') === 'true';
167
-
168
- // HIGH PRIORITY: Check if context is already forcing a specific variant for this proposal
169
- // (This happens during Live Heatmap visualization)
170
- if (context.proposalId === proposalId && context.variantLabel) {
171
- console.log(`[PROBAT HOC] Forced variant from context: ${context.variantLabel}`);
172
- setChoice({
173
- experiment_id: `forced_${proposalId}`,
174
- label: context.variantLabel
175
- });
176
- return;
177
- }
178
-
179
- // If we are in heatmap mode, bypass the cache to avoid showing stale variants
180
- const cached = isHeatmapMode ? null : readChoice(proposalId);
181
-
182
- if (cached) {
183
- const choiceData = {
184
- experiment_id: cached.experiment_id,
185
- label: cached.label,
186
- };
187
- setChoice(choiceData);
188
- // Set localStorage for heatmap tracking
189
- if (typeof window !== 'undefined' && !isHeatmapMode) {
190
- try {
191
- window.localStorage.setItem('probat_active_proposal_id', proposalId);
192
- window.localStorage.setItem('probat_active_variant_label', cached.label);
193
- } catch (e) {
194
- console.warn('[PROBAT] Failed to set proposal/variant in localStorage:', e);
195
- }
196
- }
197
- } else {
198
- (async () => {
199
- try {
200
- const { experiment_id, label } = await fetchDecision(apiBaseUrl, proposalId);
201
- if (!alive) return;
202
-
203
- // Only write to choice cache and localStorage if NOT in heatmap mode
204
- if (!isHeatmapMode) {
205
- writeChoice(proposalId, experiment_id, label);
206
- if (typeof window !== 'undefined') {
207
- try {
208
- window.localStorage.setItem('probat_active_proposal_id', proposalId);
209
- window.localStorage.setItem('probat_active_variant_label', label);
210
- } catch (e) {
211
- console.warn('[PROBAT] Failed to set proposal/variant in localStorage:', e);
212
- }
213
- }
214
- }
215
-
216
- const choiceData = { experiment_id, label };
217
- setChoice(choiceData);
218
- } catch (e) {
219
- if (!alive) return;
220
- const choiceData = {
221
- experiment_id: `exp_${proposalId}`,
222
- label: "control",
223
- };
224
- setChoice(choiceData);
225
-
226
- if (typeof window !== 'undefined' && !isHeatmapMode) {
227
- try {
228
- window.localStorage.setItem('probat_active_proposal_id', proposalId);
229
- window.localStorage.setItem('probat_active_variant_label', 'control');
230
- } catch (err) {
231
- console.warn('[PROBAT] Failed to set proposal/variant in localStorage:', err);
232
- }
233
- }
234
- }
235
- })();
236
- }
237
-
238
- return () => { alive = false; };
239
- }, [proposalId, apiBaseUrl, useNewAPI, configLoading, context.proposalId, context.variantLabel]);
240
-
241
- // Track visit
242
- useEffect(() => {
243
- if (!proposalId) return;
244
- const lbl = choice?.label ?? "control";
245
- if (!lbl || hasTrackedVisit(proposalId, lbl)) return;
246
- markTrackedVisit(proposalId, lbl);
247
- void sendMetric(apiBaseUrl, proposalId, "visit", lbl, choice?.experiment_id);
248
- }, [proposalId, choice?.experiment_id, choice?.label, apiBaseUrl]);
249
-
250
- // 4. Callback hooks - always called
251
- const trackClick = useCallback(
252
- (event?: MouseEvent | null, opts?: { force?: boolean }) => {
253
- if (!proposalId) return false;
254
- const lbl = choice?.label ?? "control";
255
- const meta = extractClickMeta(event ?? undefined);
256
- if (!opts?.force && event && !meta) return false;
257
- void sendMetric(apiBaseUrl, proposalId, "click", lbl, undefined, meta);
258
- return true;
259
- },
260
- [proposalId, choice?.experiment_id, choice?.label, apiBaseUrl]
261
- );
262
-
263
- // ============================================================
264
- // NOW WE CAN DO CONDITIONAL RETURNS - AFTER ALL HOOKS
265
- // ============================================================
266
-
267
- // Loading state - return control
268
- if (useNewAPI && (configLoading || !config || !proposalId)) {
269
- return React.createElement(ControlComponent as any, props as any);
270
- }
271
-
272
- // No proposalId - return control
273
- if (!proposalId) {
274
- return React.createElement(ControlComponent as any, props as any);
275
- }
276
-
277
- // Build registry - always has control
278
- const registry: Record<string, React.ComponentType<any>> = { control: ControlComponent };
279
-
280
- if (useNewAPI && config?.variants) {
281
- for (const [key, value] of Object.entries(config.variants)) {
282
- if (key !== 'control' && value && typeof value === 'function') {
283
- registry[key] = value;
284
- }
285
- }
286
- } else if (!useNewAPI && options.registry) {
287
- for (const [key, value] of Object.entries(options.registry)) {
288
- if (key !== 'control' && value && typeof value === 'function') {
289
- registry[key] = value;
290
- }
291
- }
292
- }
293
-
294
- // Select variant
295
- const label = choice?.label ?? "control";
296
- const Variant = registry[label] || registry.control || ControlComponent;
297
-
298
- return (
299
- <div onClick={(event) => trackClick(event)} data-probat-proposal={proposalId}>
300
- {React.createElement(Variant, {
301
- key: `${proposalId}:${label}`,
302
- ...(props as any),
303
- probat: { trackClick: () => trackClick(null, { force: true }) },
304
- })}
305
- </div>
306
- );
307
- }
308
-
309
- Wrapped.displayName = `withExperiment(${Control.displayName || Control.name || "Component"})`;
310
- return Wrapped as any;
311
- }