@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,63 @@
1
+ import "@testing-library/jest-dom/vitest";
2
+
3
+ // Mock IntersectionObserver
4
+ class MockIntersectionObserver implements IntersectionObserver {
5
+ readonly root: Element | null = null;
6
+ readonly rootMargin: string = "";
7
+ readonly thresholds: ReadonlyArray<number> = [];
8
+
9
+ private callback: IntersectionObserverCallback;
10
+ private elements: Set<Element> = new Set();
11
+
12
+ constructor(callback: IntersectionObserverCallback, _options?: IntersectionObserverInit) {
13
+ this.callback = callback;
14
+ MockIntersectionObserver._instances.push(this);
15
+ }
16
+
17
+ observe(el: Element) {
18
+ this.elements.add(el);
19
+ }
20
+
21
+ unobserve(el: Element) {
22
+ this.elements.delete(el);
23
+ }
24
+
25
+ disconnect() {
26
+ this.elements.clear();
27
+ }
28
+
29
+ takeRecords(): IntersectionObserverEntry[] {
30
+ return [];
31
+ }
32
+
33
+ // Test helpers
34
+ static _instances: MockIntersectionObserver[] = [];
35
+
36
+ static _reset() {
37
+ MockIntersectionObserver._instances = [];
38
+ }
39
+
40
+ _trigger(isIntersecting: boolean) {
41
+ const entries: IntersectionObserverEntry[] = Array.from(this.elements).map(
42
+ (el) =>
43
+ ({
44
+ target: el,
45
+ isIntersecting,
46
+ intersectionRatio: isIntersecting ? 0.6 : 0,
47
+ boundingClientRect: {} as DOMRectReadOnly,
48
+ intersectionRect: {} as DOMRectReadOnly,
49
+ rootBounds: null,
50
+ time: Date.now(),
51
+ }) as IntersectionObserverEntry
52
+ );
53
+ this.callback(entries, this);
54
+ }
55
+ }
56
+
57
+ Object.defineProperty(globalThis, "IntersectionObserver", {
58
+ value: MockIntersectionObserver,
59
+ writable: true,
60
+ });
61
+
62
+ // Export for test use
63
+ export { MockIntersectionObserver };
@@ -0,0 +1,79 @@
1
+ import { describe, it, expect, beforeEach } from "vitest";
2
+ import { makeDedupeKey, hasSeen, markSeen, resetDedupe } from "../utils/dedupeStorage";
3
+ import { getDistinctId, getSessionId, buildEventContext } from "../utils/eventContext";
4
+
5
+ // ── dedupeStorage ──────────────────────────────────────────────────────────
6
+
7
+ describe("dedupeStorage", () => {
8
+ beforeEach(() => {
9
+ sessionStorage.clear();
10
+ resetDedupe();
11
+ });
12
+
13
+ it("makeDedupeKey produces correct format", () => {
14
+ const key = makeDedupeKey("exp1", "ai_v1", "inst_1", "/pricing?plan=pro");
15
+ expect(key).toBe("probat:seen:exp1:ai_v1:inst_1:/pricing?plan=pro");
16
+ });
17
+
18
+ it("hasSeen returns false for unseen keys", () => {
19
+ expect(hasSeen("probat:seen:new:key:a:b")).toBe(false);
20
+ });
21
+
22
+ it("markSeen + hasSeen round-trips", () => {
23
+ const key = makeDedupeKey("exp1", "control", "inst_1", "/");
24
+ expect(hasSeen(key)).toBe(false);
25
+ markSeen(key);
26
+ expect(hasSeen(key)).toBe(true);
27
+ });
28
+
29
+ it("resetDedupe clears in-memory set", () => {
30
+ const key = makeDedupeKey("exp1", "control", "inst_1", "/");
31
+ markSeen(key);
32
+ expect(hasSeen(key)).toBe(true);
33
+ resetDedupe();
34
+ // sessionStorage still has it, so it should still be seen
35
+ expect(hasSeen(key)).toBe(true);
36
+ });
37
+
38
+ it("resetDedupe + clear sessionStorage fully resets", () => {
39
+ const key = makeDedupeKey("exp1", "control", "inst_1", "/");
40
+ markSeen(key);
41
+ resetDedupe();
42
+ sessionStorage.clear();
43
+ expect(hasSeen(key)).toBe(false);
44
+ });
45
+ });
46
+
47
+ // ── eventContext ────────────────────────────────────────────────────────────
48
+
49
+ describe("eventContext", () => {
50
+ beforeEach(() => {
51
+ localStorage.clear();
52
+ sessionStorage.clear();
53
+ });
54
+
55
+ it("getDistinctId returns a stable id", () => {
56
+ // Clear the module-level cache by removing from storage and re-importing
57
+ // Since the module caches in a variable, we just check consistency
58
+ const id1 = getDistinctId();
59
+ const id2 = getDistinctId();
60
+ expect(id1).toBe(id2);
61
+ expect(id1).toMatch(/^anon_|^server$/);
62
+ });
63
+
64
+ it("getSessionId returns a stable id within session", () => {
65
+ const id1 = getSessionId();
66
+ const id2 = getSessionId();
67
+ expect(id1).toBe(id2);
68
+ expect(id1).toMatch(/^sess_|^server$/);
69
+ });
70
+
71
+ it("buildEventContext includes all required fields", () => {
72
+ const ctx = buildEventContext();
73
+ expect(ctx).toHaveProperty("distinct_id");
74
+ expect(ctx).toHaveProperty("session_id");
75
+ expect(ctx).toHaveProperty("$page_url");
76
+ expect(ctx).toHaveProperty("$pathname");
77
+ expect(ctx).toHaveProperty("$referrer");
78
+ });
79
+ });
@@ -0,0 +1,291 @@
1
+ "use client";
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
+ }
44
+
45
+ // ── Types ──────────────────────────────────────────────────────────────────
46
+
47
+ export interface ExperimentTrackOptions {
48
+ /** Auto-track impressions (default true) */
49
+ impression?: boolean;
50
+ /** Auto-track clicks (default true) */
51
+ primaryClick?: boolean;
52
+ /** Custom impression event name (default "$experiment_exposure") */
53
+ impressionEventName?: string;
54
+ /** Custom click event name (default "$experiment_click") */
55
+ clickEventName?: string;
56
+ }
57
+
58
+ export interface ExperimentProps {
59
+ /** Experiment key / identifier */
60
+ id: string;
61
+ /** Control variant ReactNode */
62
+ control: React.ReactNode;
63
+ /** Named variant ReactNodes, keyed by variant key (e.g. { ai_v1: <MyVariant /> }) */
64
+ variants: Record<string, React.ReactNode>;
65
+ /** Tracking configuration */
66
+ track?: ExperimentTrackOptions;
67
+ /** Stable instance id when multiple instances of the same experiment exist on a page */
68
+ componentInstanceId?: string;
69
+ /** Behavior when assignment fetch fails: "control" (default) renders control, "suspend" throws */
70
+ fallback?: "control" | "suspend";
71
+ /** Log decisions + events to console */
72
+ debug?: boolean;
73
+ }
74
+
75
+ export function Experiment({
76
+ id,
77
+ control,
78
+ variants,
79
+ track,
80
+ componentInstanceId,
81
+ fallback = "control",
82
+ debug = false,
83
+ }: ExperimentProps) {
84
+ const { host, bootstrap } = 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
+ // Sync resolution order: bootstrap → localStorage → "control"
100
+ if (bootstrap[id]) return bootstrap[id];
101
+ const cached = readAssignment(id);
102
+ if (cached) return cached;
103
+ return "control";
104
+ });
105
+ const [resolved, setResolved] = useState<boolean>(() => {
106
+ return !!(bootstrap[id] || readAssignment(id));
107
+ });
108
+
109
+ useEffect(() => {
110
+ // Already resolved from bootstrap or cache
111
+ if (bootstrap[id] || readAssignment(id)) {
112
+ // Ensure state is synced (StrictMode may re-mount)
113
+ const key = bootstrap[id] ?? readAssignment(id) ?? "control";
114
+ setVariantKey(key);
115
+ setResolved(true);
116
+ return;
117
+ }
118
+
119
+ let cancelled = false;
120
+
121
+ (async () => {
122
+ try {
123
+ const distinctId = getDistinctId();
124
+ const key = await fetchDecision(host, id, distinctId);
125
+ if (cancelled) return;
126
+
127
+ // Validate variant key
128
+ if (key !== "control" && !(key in variants)) {
129
+ if (debug) {
130
+ console.warn(
131
+ `[probat] Unknown variant "${key}" for experiment "${id}", falling back to control`
132
+ );
133
+ }
134
+ setVariantKey("control");
135
+ } else {
136
+ setVariantKey(key);
137
+ writeAssignment(id, key);
138
+ }
139
+ } catch (err) {
140
+ if (cancelled) return;
141
+ if (debug) {
142
+ console.error(`[probat] fetchDecision failed for "${id}":`, err);
143
+ }
144
+ if (fallback === "suspend") throw err;
145
+ setVariantKey("control");
146
+ } finally {
147
+ if (!cancelled) setResolved(true);
148
+ }
149
+ })();
150
+
151
+ return () => {
152
+ cancelled = true;
153
+ };
154
+ }, [id, host]); // eslint-disable-line react-hooks/exhaustive-deps
155
+
156
+ // ── Debug logging ──────────────────────────────────────────────────────
157
+
158
+ useEffect(() => {
159
+ if (debug && resolved) {
160
+ console.log(`[probat] Experiment "${id}" → variant "${variantKey}"`, {
161
+ instanceId,
162
+ pageKey: getPageKey(),
163
+ });
164
+ }
165
+ }, [debug, id, variantKey, resolved, instanceId]);
166
+
167
+ // ── Shared event properties ────────────────────────────────────────────
168
+
169
+ const eventProps = useMemo(
170
+ () => ({
171
+ experiment_id: id,
172
+ variant_key: variantKey,
173
+ component_instance_id: instanceId,
174
+ }),
175
+ [id, variantKey, instanceId]
176
+ );
177
+
178
+ // ── Impression tracking via IntersectionObserver ────────────────────────
179
+
180
+ const containerRef = useRef<HTMLDivElement>(null);
181
+ const impressionSent = useRef(false);
182
+
183
+ useEffect(() => {
184
+ if (!trackImpression || !resolved) return;
185
+
186
+ // Reset on re-mount (StrictMode safety)
187
+ impressionSent.current = false;
188
+
189
+ const pageKey = getPageKey();
190
+ const dedupeKey = makeDedupeKey(id, variantKey, instanceId, pageKey);
191
+
192
+ // Already seen this session
193
+ if (hasSeen(dedupeKey)) {
194
+ impressionSent.current = true;
195
+ return;
196
+ }
197
+
198
+ const el = containerRef.current;
199
+ if (!el) return;
200
+
201
+ // Fallback: no IntersectionObserver (SSR, old browser)
202
+ if (typeof IntersectionObserver === "undefined") {
203
+ if (!impressionSent.current) {
204
+ impressionSent.current = true;
205
+ markSeen(dedupeKey);
206
+ sendMetric(host, impressionEvent, eventProps);
207
+ if (debug) console.log(`[probat] Impression sent (no IO) for "${id}"`);
208
+ }
209
+ return;
210
+ }
211
+
212
+ let timer: ReturnType<typeof setTimeout> | null = null;
213
+
214
+ const observer = new IntersectionObserver(
215
+ ([entry]) => {
216
+ if (!entry || impressionSent.current) return;
217
+
218
+ if (entry.isIntersecting) {
219
+ timer = setTimeout(() => {
220
+ if (impressionSent.current) return;
221
+ impressionSent.current = true;
222
+ markSeen(dedupeKey);
223
+ sendMetric(host, impressionEvent, eventProps);
224
+ if (debug) console.log(`[probat] Impression sent for "${id}"`);
225
+ observer.disconnect();
226
+ }, 250);
227
+ } else if (timer) {
228
+ clearTimeout(timer);
229
+ timer = null;
230
+ }
231
+ },
232
+ { threshold: 0.5 }
233
+ );
234
+
235
+ observer.observe(el);
236
+
237
+ return () => {
238
+ observer.disconnect();
239
+ if (timer) clearTimeout(timer);
240
+ };
241
+ }, [
242
+ trackImpression,
243
+ resolved,
244
+ id,
245
+ variantKey,
246
+ instanceId,
247
+ host,
248
+ impressionEvent,
249
+ eventProps,
250
+ debug,
251
+ ]);
252
+
253
+ // ── Click tracking ─────────────────────────────────────────────────────
254
+
255
+ const handleClick = useCallback(
256
+ (e: React.MouseEvent) => {
257
+ if (!trackClick) return;
258
+
259
+ const meta = extractClickMeta(e.target as EventTarget);
260
+ if (!meta) return;
261
+
262
+ sendMetric(host, clickEvent, {
263
+ ...eventProps,
264
+ ...meta,
265
+ });
266
+ if (debug) {
267
+ console.log(`[probat] Click tracked for "${id}"`, meta);
268
+ }
269
+ },
270
+ [trackClick, host, clickEvent, eventProps, id, debug]
271
+ );
272
+
273
+ // ── Render ─────────────────────────────────────────────────────────────
274
+
275
+ const content =
276
+ variantKey === "control" || !(variantKey in variants)
277
+ ? control
278
+ : variants[variantKey];
279
+
280
+ return (
281
+ <div
282
+ ref={containerRef}
283
+ onClick={handleClick}
284
+ data-probat-experiment={id}
285
+ data-probat-variant={variantKey}
286
+ style={{ display: "block", margin: 0, padding: 0 }}
287
+ >
288
+ {content}
289
+ </div>
290
+ );
291
+ }
@@ -1,18 +1,30 @@
1
1
  "use client";
2
2
 
3
3
  import React from "react";
4
- import { ProbatProvider as BaseProbatProvider } from "../context/ProbatContext";
4
+ import { ProbatProvider } from "../context/ProbatContext";
5
5
  import type { ProbatProviderProps } from "../context/ProbatContext";
6
6
 
7
7
  /**
8
- * ProbatProviderClient - Can be imported directly in Next.js Server Components
9
- * This is a re-export with "use client" directive to ensure it works in Server Components
8
+ * Client-only provider for Next.js App Router.
9
+ * Import this in your layout/providers file.
10
+ *
11
+ * @example
12
+ * ```tsx
13
+ * // app/providers.tsx
14
+ * "use client";
15
+ * import { ProbatProviderClient } from "@probat/react";
16
+ *
17
+ * export function Providers({ children }) {
18
+ * return (
19
+ * <ProbatProviderClient userId="your-user-uuid">
20
+ * {children}
21
+ * </ProbatProviderClient>
22
+ * );
23
+ * }
24
+ * ```
10
25
  */
11
26
  export function ProbatProviderClient(props: ProbatProviderProps) {
12
- return React.createElement(BaseProbatProvider, props);
27
+ return React.createElement(ProbatProvider, props);
13
28
  }
14
29
 
15
- // Also export as ProbatProvider for convenience
16
- export { ProbatProviderClient as ProbatProvider };
17
30
  export type { ProbatProviderProps };
18
-
@@ -1,181 +1,59 @@
1
1
  "use client";
2
2
 
3
- import React, { createContext, useContext, useMemo, useEffect } from "react";
4
- import { detectEnvironment } from "../utils/environment";
5
- import { initHeatmapTracking, stopHeatmapTracking } from "../utils/heatmapTracker";
6
-
7
- declare global {
8
- interface Window {
9
- __PROBAT_API?: string;
10
- }
11
- }
3
+ import React, { createContext, useContext, useMemo } from "react";
12
4
 
13
5
  export interface ProbatContextValue {
14
- apiBaseUrl: string;
15
- environment: "dev" | "prod";
16
- clientKey?: string;
17
- repoFullName?: string; // Repository full name (e.g., "owner/repo") for component-based experiments
18
- proposalId?: string;
19
- variantLabel?: string;
6
+ host: string;
7
+ userId: string;
8
+ bootstrap: Record<string, string>;
20
9
  }
21
10
 
22
11
  const ProbatContext = createContext<ProbatContextValue | null>(null);
23
12
 
13
+ const DEFAULT_HOST = "https://gushi.onrender.com";
14
+
24
15
  export interface ProbatProviderProps {
16
+ /** Gushi user ID (UUID) — used to scope SDK requests to a customer */
17
+ userId: string;
18
+ /** Base URL for the Probat API. Defaults to https://gushi.onrender.com */
19
+ host?: string;
25
20
  /**
26
- * The base URL for the Probat API.
27
- * If not provided, will try to read from:
28
- * - VITE_PROBAT_API (Vite)
29
- * - NEXT_PUBLIC_PROBAT_API (Next.js)
30
- * - window.__PROBAT_API
31
- * - Default: "https://gushi.onrender.com"
32
- */
33
- apiBaseUrl?: string;
34
- /**
35
- * Optional: proposal/experiment id for heatmap segregation
36
- */
37
- proposalId?: string;
38
- /**
39
- * Optional: variant label for heatmap segregation
40
- */
41
- variantLabel?: string;
42
- /**
43
- * Client key for identification (optional)
44
- */
45
- clientKey?: string;
46
- /**
47
- * Explicitly set environment. If not provided, will auto-detect based on hostname.
48
- * "dev" for localhost, "prod" for production.
49
- */
50
- environment?: "dev" | "prod";
51
- /**
52
- * Repository full name (e.g., "owner/repo") for component-based experiments.
53
- * If not provided, will try to read from:
54
- * - NEXT_PUBLIC_PROBAT_REPO (Next.js)
55
- * - VITE_PROBAT_REPO (Vite)
56
- * - window.__PROBAT_REPO
21
+ * Bootstrap assignments to avoid flash on first render.
22
+ * Map of experiment id variant key.
23
+ * e.g. { "cta-copy-test": "ai_v1" }
57
24
  */
58
- repoFullName?: string;
25
+ bootstrap?: Record<string, string>;
59
26
  children: React.ReactNode;
60
27
  }
61
28
 
62
29
  export function ProbatProvider({
63
- apiBaseUrl,
64
- clientKey,
65
- environment: explicitEnvironment,
66
- repoFullName: explicitRepoFullName,
67
- proposalId,
68
- variantLabel,
30
+ userId,
31
+ host = DEFAULT_HOST,
32
+ bootstrap,
69
33
  children,
70
34
  }: ProbatProviderProps) {
71
- // Fallback to localStorage for experiment info if props are not provided
72
- const storedProposalId =
73
- typeof window !== "undefined"
74
- ? window.localStorage.getItem("probat_active_proposal_id") || undefined
75
- : undefined;
76
- const storedVariantLabel =
77
- typeof window !== "undefined"
78
- ? window.localStorage.getItem("probat_active_variant_label") || undefined
79
- : undefined;
80
-
81
- const contextValue = useMemo<ProbatContextValue>(() => {
82
- // Determine API base URL
83
- const resolvedApiBaseUrl =
84
- apiBaseUrl ||
85
- (typeof import.meta !== "undefined" &&
86
- (import.meta as any).env?.VITE_PROBAT_API) ||
87
- (typeof globalThis !== "undefined" &&
88
- (globalThis as any).process?.env?.NEXT_PUBLIC_PROBAT_API) ||
89
- (typeof window !== "undefined" && window.__PROBAT_API) ||
90
- "https://gushi.onrender.com";
91
-
92
- // Determine environment
93
- const environment = explicitEnvironment || detectEnvironment();
94
-
95
- // Determine repo full name
96
- const resolvedRepoFullName =
97
- explicitRepoFullName ||
98
- (typeof globalThis !== "undefined" &&
99
- (globalThis as any).process?.env?.NEXT_PUBLIC_PROBAT_REPO) ||
100
- (typeof import.meta !== "undefined" &&
101
- (import.meta as any).env?.VITE_PROBAT_REPO) ||
102
- (typeof window !== "undefined" && (window as any).__PROBAT_REPO) ||
103
- undefined;
104
-
105
- // Check for URL overrides (used for Live Heatmap visualization)
106
- const params = (typeof window !== "undefined") ? new URLSearchParams(window.location.search) : null;
107
- const isHeatmapMode = params?.get('heatmap') === 'true';
108
-
109
- let urlProposalId: string | undefined;
110
- let urlVariantLabel: string | undefined;
111
-
112
- if (isHeatmapMode && params) {
113
- urlProposalId = params.get('proposal_id') || undefined;
114
- urlVariantLabel = params.get('variant_label') || undefined;
115
- console.log('[PROBAT] Heatmap mode: Overriding variant from URL', { urlProposalId, urlVariantLabel });
116
- }
117
-
118
- // Priority Logic:
119
- // 1. URL params (if in heatmap mode)
120
- // 2. Explicit props passed to Provider
121
- // 3. Stored values in localStorage (ONLY if NOT in heatmap mode)
122
- const finalProposalId = urlProposalId || proposalId || (!isHeatmapMode ? storedProposalId : undefined);
123
- const finalVariantLabel = urlVariantLabel || variantLabel || (!isHeatmapMode ? storedVariantLabel : undefined);
124
-
125
- return {
126
- apiBaseUrl: resolvedApiBaseUrl,
127
- environment,
128
- clientKey,
129
- repoFullName: resolvedRepoFullName,
130
- proposalId: finalProposalId,
131
- variantLabel: finalVariantLabel,
132
- };
133
- }, [apiBaseUrl, clientKey, explicitEnvironment, explicitRepoFullName, proposalId, variantLabel, storedProposalId, storedVariantLabel]);
134
-
135
- // Initialize heatmap tracking when provider mounts
136
- useEffect(() => {
137
- // Only initialize on client-side
138
- if (typeof window !== 'undefined') {
139
- initHeatmapTracking({
140
- apiBaseUrl: contextValue.apiBaseUrl,
141
- batchSize: 10,
142
- batchInterval: 5000,
143
- enabled: true,
144
- excludeSelectors: [
145
- '[data-heatmap-exclude]',
146
- 'input[type="password"]',
147
- 'input[type="email"]',
148
- 'textarea',
149
- ],
150
- // Explicitly enable cursor tracking with sensible defaults
151
- trackCursor: true,
152
- cursorThrottle: 100, // capture every 100ms
153
- cursorBatchSize: 50, // send every 50 movements (or after batchInterval)
154
- proposalId: contextValue.proposalId,
155
- variantLabel: contextValue.variantLabel,
156
- });
157
- }
158
-
159
- // Cleanup on unmount
160
- return () => {
161
- stopHeatmapTracking();
162
- };
163
- }, [contextValue.apiBaseUrl, contextValue.proposalId, contextValue.variantLabel]);
35
+ const value = useMemo<ProbatContextValue>(
36
+ () => ({
37
+ host: host.replace(/\/$/, ""),
38
+ userId,
39
+ bootstrap: bootstrap ?? {},
40
+ }),
41
+ [userId, host, bootstrap]
42
+ );
164
43
 
165
44
  return (
166
- <ProbatContext.Provider value={contextValue}>
45
+ <ProbatContext.Provider value={value}>
167
46
  {children}
168
47
  </ProbatContext.Provider>
169
48
  );
170
49
  }
171
50
 
172
51
  export function useProbatContext(): ProbatContextValue {
173
- const context = useContext(ProbatContext);
174
- if (!context) {
52
+ const ctx = useContext(ProbatContext);
53
+ if (!ctx) {
175
54
  throw new Error(
176
- "useProbatContext must be used within a ProbatProvider. Please wrap your app with <ProbatProvider>."
55
+ "useProbatContext must be used within <ProbatProviderClient>. Wrap your app with <ProbatProviderClient userId={...}>."
177
56
  );
178
57
  }
179
- return context;
58
+ return ctx;
180
59
  }
181
-