@traffical/react 0.1.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.
package/src/hooks.ts ADDED
@@ -0,0 +1,656 @@
1
+ /**
2
+ * Traffical React Hooks
3
+ *
4
+ * React hooks for parameter resolution and decision tracking.
5
+ * Uses the browser-optimized JS Client for full feature support.
6
+ */
7
+
8
+ import { useState, useEffect, useCallback, useRef } from "react";
9
+ import type {
10
+ ParameterValue,
11
+ DecisionResult,
12
+ Context,
13
+ } from "@traffical/core";
14
+ import { resolveParameters } from "@traffical/core";
15
+ import type { TrafficalPlugin } from "@traffical/js-client";
16
+ import { useTrafficalContext } from "./context.js";
17
+
18
+ // =============================================================================
19
+ // Internal Utilities
20
+ // =============================================================================
21
+
22
+ /**
23
+ * Creates a stable string key from an object for use in dependency arrays.
24
+ * This prevents infinite re-renders when users pass inline objects to hooks.
25
+ *
26
+ * Uses JSON.stringify with sorted keys to ensure consistent ordering.
27
+ */
28
+ function createStableKey(obj: unknown): string {
29
+ if (obj === null || obj === undefined) {
30
+ return String(obj);
31
+ }
32
+ if (typeof obj !== "object") {
33
+ return String(obj);
34
+ }
35
+ // Sort keys for consistent ordering
36
+ return JSON.stringify(obj, Object.keys(obj as object).sort());
37
+ }
38
+
39
+ /**
40
+ * Custom hook that returns a stable reference to an object.
41
+ * Only updates the reference when the object's serialized value changes.
42
+ *
43
+ * This allows users to pass inline objects without causing infinite re-renders:
44
+ * ```tsx
45
+ * // This now works without memoization!
46
+ * const { params } = useTraffical({
47
+ * defaults: { 'ui.color': '#000' }, // inline object is fine
48
+ * });
49
+ * ```
50
+ */
51
+ function useStableObject<T>(obj: T): T {
52
+ const stableKey = createStableKey(obj);
53
+ const ref = useRef(obj);
54
+
55
+ // Only update the ref when the serialized value actually changes
56
+ // This is safe because we're comparing by value, not reference
57
+ if (createStableKey(ref.current) !== stableKey) {
58
+ ref.current = obj;
59
+ }
60
+
61
+ return ref.current;
62
+ }
63
+
64
+ // =============================================================================
65
+ // useTraffical - Primary Hook
66
+ // =============================================================================
67
+
68
+ /**
69
+ * Options for the useTraffical hook.
70
+ */
71
+ export interface UseTrafficalOptions<T> {
72
+ /** Default parameter values */
73
+ defaults: T;
74
+
75
+ /** Additional context (optional) */
76
+ context?: Context;
77
+
78
+ /**
79
+ * Tracking mode (default: "full")
80
+ * - "full": Track decision + exposure (default, recommended for UI)
81
+ * - "decision": Track decision only, manual exposure control
82
+ * - "none": No tracking (SSR, internal logic, tests)
83
+ */
84
+ tracking?: "full" | "decision" | "none";
85
+ }
86
+
87
+ /**
88
+ * Options for the bound track function returned by useTraffical.
89
+ */
90
+ export interface BoundTrackOptions {
91
+ /** Additional event properties */
92
+ properties?: Record<string, unknown>;
93
+ }
94
+
95
+ /**
96
+ * @deprecated Use BoundTrackOptions instead.
97
+ */
98
+ export interface BoundTrackRewardOptions {
99
+ /** The reward value (e.g., revenue amount, conversion count) */
100
+ reward: number;
101
+ /** Type of reward (e.g., "revenue", "conversion", "engagement") */
102
+ rewardType?: string;
103
+ /** Multiple reward values keyed by type */
104
+ rewards?: Record<string, number>;
105
+ }
106
+
107
+ /**
108
+ * Return value from the useTraffical hook.
109
+ */
110
+ export interface UseTrafficalResult<T> {
111
+ /** Resolved parameter values */
112
+ params: T;
113
+ /** The full decision result (null when tracking="none") */
114
+ decision: DecisionResult | null;
115
+ /** Whether the client is ready (config loaded) */
116
+ ready: boolean;
117
+ /** Any error that occurred */
118
+ error: Error | null;
119
+ /** Function to manually track exposure (no-op when tracking="none") */
120
+ trackExposure: () => void;
121
+ /**
122
+ * Track a user event. The decisionId is automatically bound.
123
+ * No-op if tracking="none" or no decision is available.
124
+ *
125
+ * @example
126
+ * track('purchase', { value: 99.99, orderId: 'ord_123' });
127
+ * track('add_to_cart', { itemId: 'sku_456' });
128
+ */
129
+ track: (event: string, properties?: Record<string, unknown>) => void;
130
+ /**
131
+ * @deprecated Use track() instead.
132
+ * Track a reward for this decision. The decisionId is automatically bound.
133
+ * No-op if tracking="none" or no decision is available.
134
+ */
135
+ trackReward: (options: BoundTrackRewardOptions) => void;
136
+ }
137
+
138
+ /**
139
+ * Primary hook for Traffical parameter resolution and decision tracking.
140
+ *
141
+ * On first render, returns defaults immediately (no blocking).
142
+ * When the config bundle loads, recomputes and returns resolved values.
143
+ *
144
+ * @example
145
+ * ```tsx
146
+ * // Full tracking (default) - decision + exposure events
147
+ * const { params, decision, ready } = useTraffical({
148
+ * defaults: { "checkout.ctaText": "Buy Now" },
149
+ * });
150
+ *
151
+ * // Decision tracking only - manual exposure control
152
+ * const { params, decision, trackExposure } = useTraffical({
153
+ * defaults: { "checkout.ctaText": "Buy Now" },
154
+ * tracking: "decision",
155
+ * });
156
+ *
157
+ * // No tracking - for SSR, tests, or internal logic
158
+ * const { params, ready } = useTraffical({
159
+ * defaults: { "ui.hero.title": "Welcome" },
160
+ * tracking: "none",
161
+ * });
162
+ * ```
163
+ */
164
+ export function useTraffical<T extends Record<string, ParameterValue>>(
165
+ options: UseTrafficalOptions<T>
166
+ ): UseTrafficalResult<T> {
167
+ const { client, ready, error, getContext, getUnitKey, initialParams, localConfig } =
168
+ useTrafficalContext();
169
+
170
+ const trackingMode = options.tracking ?? "full";
171
+ const shouldTrackDecision = trackingMode !== "none";
172
+ const shouldAutoTrackExposure = trackingMode === "full";
173
+
174
+ // Create stable references for objects to prevent infinite re-renders
175
+ // when users pass inline objects like: useTraffical({ defaults: { ... } })
176
+ const stableDefaults = useStableObject(options.defaults);
177
+ const stableContext = useStableObject(options.context);
178
+
179
+ // Track if we resolved synchronously (to avoid duplicate resolution in useEffect)
180
+ const resolvedSyncRef = useRef(false);
181
+ const syncDecisionRef = useRef<DecisionResult | null>(null);
182
+
183
+ // State - resolve synchronously if possible to prevent flicker
184
+ const [params, setParams] = useState<T>(() => {
185
+ // If client is already ready (e.g., subsequent page navigation), resolve synchronously
186
+ // This prevents the default -> resolved flicker (classic A/B testing problem)
187
+ if (client && ready) {
188
+ resolvedSyncRef.current = true;
189
+
190
+ const context: Context = {
191
+ ...getContext(),
192
+ ...(options.context ?? {}),
193
+ };
194
+
195
+ if (shouldTrackDecision) {
196
+ // Use decide() for tracked decisions
197
+ const result = client.decide({
198
+ context,
199
+ defaults: options.defaults,
200
+ });
201
+ syncDecisionRef.current = result;
202
+ return result.assignments as T;
203
+ } else {
204
+ // Use getParams() for untracked
205
+ return client.getParams({
206
+ context,
207
+ defaults: options.defaults,
208
+ }) as T;
209
+ }
210
+ }
211
+
212
+ // NEW: If we have localConfig bundle, resolve synchronously even before client is ready
213
+ // This is the key to flicker-free SSR: server and client both resolve from the same bundle
214
+ if (localConfig) {
215
+ try {
216
+ const context = getContext();
217
+ // Only resolve if we have a userId (set by server via cookie/header)
218
+ if (context.userId) {
219
+ resolvedSyncRef.current = true;
220
+ const fullContext: Context = {
221
+ ...context,
222
+ ...(options.context ?? {}),
223
+ };
224
+ // Use pure resolution function from core - no tracking on initial render
225
+ const resolved = resolveParameters(localConfig, fullContext, options.defaults);
226
+ return resolved as T;
227
+ }
228
+ } catch {
229
+ // Context function not ready, fall through to defaults
230
+ }
231
+ }
232
+
233
+ // Fallback to defaults (no localConfig or no userId)
234
+ if (initialParams) {
235
+ return { ...stableDefaults, ...initialParams } as T;
236
+ }
237
+ return stableDefaults;
238
+ });
239
+
240
+ const [decision, setDecision] = useState<DecisionResult | null>(
241
+ () => syncDecisionRef.current
242
+ );
243
+ const [hasTrackedExposure, setHasTrackedExposure] = useState(false);
244
+
245
+ // Manual exposure tracking (synchronous - batched internally)
246
+ const trackExposure = useCallback(() => {
247
+ // No-op when tracking is "none"
248
+ if (trackingMode === "none") {
249
+ return;
250
+ }
251
+ if (!client || !decision || hasTrackedExposure) {
252
+ return;
253
+ }
254
+ client.trackExposure(decision);
255
+ setHasTrackedExposure(true);
256
+ }, [client, decision, hasTrackedExposure, trackingMode]);
257
+
258
+ // Resolve params or make decision when client is ready
259
+ useEffect(() => {
260
+ if (!client || !ready) {
261
+ return;
262
+ }
263
+
264
+ // Build context using stable references
265
+ const context: Context = {
266
+ ...getContext(),
267
+ ...stableContext,
268
+ };
269
+
270
+ if (shouldTrackDecision) {
271
+ // Use decide() - tracks decision event via DecisionTrackingPlugin
272
+ const result = client.decide({
273
+ context,
274
+ defaults: stableDefaults,
275
+ });
276
+
277
+ // Only update state if we didn't already resolve synchronously
278
+ // This prevents the params from flickering (default -> resolved)
279
+ // But we ALWAYS call decide() to ensure tracking happens
280
+ if (!resolvedSyncRef.current) {
281
+ setParams(result.assignments as T);
282
+ }
283
+ setDecision(result);
284
+ setHasTrackedExposure(false);
285
+ } else {
286
+ // Use getParams() - no tracking
287
+ if (!resolvedSyncRef.current) {
288
+ const resolved = client.getParams({
289
+ context,
290
+ defaults: stableDefaults,
291
+ });
292
+ setParams(resolved as T);
293
+ }
294
+ setDecision(null);
295
+ }
296
+
297
+ // Clear the sync flag after first effect run
298
+ resolvedSyncRef.current = false;
299
+ }, [client, ready, getContext, stableContext, stableDefaults, shouldTrackDecision]);
300
+
301
+ // Auto-track exposure when tracking is "full"
302
+ useEffect(() => {
303
+ if (shouldAutoTrackExposure && decision && !hasTrackedExposure) {
304
+ trackExposure();
305
+ }
306
+ }, [shouldAutoTrackExposure, decision, hasTrackedExposure, trackExposure]);
307
+
308
+ // Ref to store current decision for stable track function reference
309
+ const decisionRef = useRef<DecisionResult | null>(null);
310
+ decisionRef.current = decision;
311
+
312
+ // Buffer for track events that arrive before decision is ready
313
+ // This prevents race conditions where track() is called in a useEffect
314
+ // that runs before the decision has been set
315
+ const pendingTracksRef = useRef<Array<{ event: string; properties?: Record<string, unknown> }>>([]);
316
+
317
+ // Flush pending track events when decision becomes available
318
+ useEffect(() => {
319
+ if (decision && client && pendingTracksRef.current.length > 0) {
320
+ const pending = pendingTracksRef.current;
321
+ pendingTracksRef.current = [];
322
+
323
+ for (const { event, properties } of pending) {
324
+ client.track(event, properties, {
325
+ decisionId: decision.decisionId,
326
+ unitKey: getUnitKey(),
327
+ });
328
+ }
329
+ }
330
+ }, [decision, client, getUnitKey]);
331
+
332
+ // Track user events - decisionId is automatically included
333
+ // If decision isn't ready yet, events are queued and flushed when it becomes available
334
+ const track = useCallback(
335
+ (event: string, properties?: Record<string, unknown>) => {
336
+ if (!client) {
337
+ console.warn("[Traffical] Client not initialized, cannot track event");
338
+ return;
339
+ }
340
+
341
+ const currentDecision = decisionRef.current;
342
+ if (!currentDecision) {
343
+ // Queue the event instead of dropping it - will be flushed when decision is ready
344
+ // This handles the race condition where track() is called before decision is set
345
+ if (trackingMode === "none") {
346
+ console.warn(
347
+ "[Traffical] Cannot track event with tracking: 'none'. Use tracking: 'full' or 'decision'."
348
+ );
349
+ return;
350
+ }
351
+ pendingTracksRef.current.push({ event, properties });
352
+ return;
353
+ }
354
+
355
+ client.track(event, properties, {
356
+ decisionId: currentDecision.decisionId,
357
+ unitKey: getUnitKey(),
358
+ });
359
+ },
360
+ [client, getUnitKey, trackingMode]
361
+ );
362
+
363
+ // Deprecated: Bound reward tracking - decisionId is automatically included
364
+ const trackReward = useCallback(
365
+ (options: BoundTrackRewardOptions) => {
366
+ if (!client) {
367
+ console.warn("[Traffical] Client not initialized, cannot track reward");
368
+ return;
369
+ }
370
+ const currentDecision = decisionRef.current;
371
+ if (!currentDecision) {
372
+ console.warn(
373
+ "[Traffical] No decision available, cannot track reward. Did you use tracking: 'none'?"
374
+ );
375
+ return;
376
+ }
377
+ // Map old API to new track() API
378
+ track(options.rewardType || "reward", {
379
+ value: options.reward,
380
+ ...(options.rewards ? { rewards: options.rewards } : {}),
381
+ });
382
+ },
383
+ [client, track]
384
+ );
385
+
386
+ return { params, decision, ready, error, trackExposure, track, trackReward };
387
+ }
388
+
389
+ // =============================================================================
390
+ // Deprecated Hooks (for backward compatibility)
391
+ // =============================================================================
392
+
393
+ /**
394
+ * Options for useTrafficalParams hook.
395
+ * @deprecated Use UseTrafficalOptions instead.
396
+ */
397
+ export interface UseTrafficalParamsOptions<T extends Record<string, ParameterValue>> {
398
+ /** Default values for parameters */
399
+ defaults: T;
400
+ /** Additional context to merge (optional) */
401
+ context?: Context;
402
+ }
403
+
404
+ /**
405
+ * Return value from useTrafficalParams hook.
406
+ * @deprecated Use UseTrafficalResult instead.
407
+ */
408
+ export interface UseTrafficalParamsResult<T> {
409
+ /** Resolved parameter values */
410
+ params: T;
411
+ /** Whether the client is ready (config loaded) */
412
+ ready: boolean;
413
+ /** Any error that occurred */
414
+ error: Error | null;
415
+ }
416
+
417
+ /**
418
+ * Hook to get resolved parameter values.
419
+ *
420
+ * @deprecated Use `useTraffical({ tracking: "none" })` instead.
421
+ *
422
+ * @example
423
+ * ```tsx
424
+ * // Old way (deprecated)
425
+ * const { params, ready } = useTrafficalParams({ defaults: { ... } });
426
+ *
427
+ * // New way
428
+ * const { params, ready } = useTraffical({ defaults: { ... }, tracking: "none" });
429
+ * ```
430
+ */
431
+ export function useTrafficalParams<T extends Record<string, ParameterValue>>(
432
+ options: UseTrafficalParamsOptions<T>
433
+ ): UseTrafficalParamsResult<T> {
434
+ // Show deprecation warning in development only
435
+ useEffect(() => {
436
+ if (process.env.NODE_ENV === "development") {
437
+ console.warn(
438
+ '[Traffical] useTrafficalParams is deprecated. Use useTraffical({ tracking: "none" }) instead.'
439
+ );
440
+ }
441
+ }, []);
442
+
443
+ const result = useTraffical({ ...options, tracking: "none" });
444
+ return { params: result.params, ready: result.ready, error: result.error };
445
+ }
446
+
447
+ /**
448
+ * Options for useTrafficalDecision hook.
449
+ * @deprecated Use UseTrafficalOptions instead.
450
+ */
451
+ export interface UseTrafficalDecisionOptions<T extends Record<string, ParameterValue>> {
452
+ /** Default values for parameters */
453
+ defaults: T;
454
+ /** Additional context to merge (optional) */
455
+ context?: Context;
456
+ /**
457
+ * Whether to automatically track exposure (default: true).
458
+ * Set to false if you want to manually control when exposure is tracked
459
+ * (e.g., when an element scrolls into view).
460
+ *
461
+ * Note: Decision tracking happens automatically via DecisionTrackingPlugin
462
+ * and is separate from exposure tracking.
463
+ */
464
+ trackExposure?: boolean;
465
+ }
466
+
467
+ /**
468
+ * Return value from useTrafficalDecision hook.
469
+ * @deprecated Use UseTrafficalResult instead.
470
+ */
471
+ export interface UseTrafficalDecisionResult<T> {
472
+ /** Resolved parameter values */
473
+ params: T;
474
+ /** The full decision result (for tracking) */
475
+ decision: DecisionResult | null;
476
+ /** Whether the client is ready (config loaded) */
477
+ ready: boolean;
478
+ /** Any error that occurred */
479
+ error: Error | null;
480
+ /**
481
+ * Function to manually track exposure.
482
+ * Note: This is synchronous - events are batched internally.
483
+ */
484
+ trackExposure: () => void;
485
+ }
486
+
487
+ /**
488
+ * Hook to get a decision with full metadata for tracking.
489
+ *
490
+ * @deprecated Use `useTraffical()` instead.
491
+ *
492
+ * @example
493
+ * ```tsx
494
+ * // Old way (deprecated)
495
+ * const { params, decision } = useTrafficalDecision({ defaults: { ... } });
496
+ *
497
+ * // New way
498
+ * const { params, decision } = useTraffical({ defaults: { ... } });
499
+ *
500
+ * // Old way with manual exposure (deprecated)
501
+ * const { params, trackExposure } = useTrafficalDecision({ defaults: { ... }, trackExposure: false });
502
+ *
503
+ * // New way with manual exposure
504
+ * const { params, trackExposure } = useTraffical({ defaults: { ... }, tracking: "decision" });
505
+ * ```
506
+ */
507
+ export function useTrafficalDecision<T extends Record<string, ParameterValue>>(
508
+ options: UseTrafficalDecisionOptions<T>
509
+ ): UseTrafficalDecisionResult<T> {
510
+ // Show deprecation warning in development only
511
+ useEffect(() => {
512
+ if (process.env.NODE_ENV === "development") {
513
+ console.warn(
514
+ "[Traffical] useTrafficalDecision is deprecated. Use useTraffical() instead."
515
+ );
516
+ }
517
+ }, []);
518
+
519
+ // Map old trackExposure option to new tracking mode
520
+ const tracking = options.trackExposure === false ? "decision" : "full";
521
+ return useTraffical({ defaults: options.defaults, context: options.context, tracking });
522
+ }
523
+
524
+ /**
525
+ * Hook to track user events.
526
+ *
527
+ * @example
528
+ * ```tsx
529
+ * const track = useTrafficalTrack();
530
+ *
531
+ * const handlePurchase = (amount: number) => {
532
+ * track('purchase', { value: amount, orderId: 'ord_123' });
533
+ * };
534
+ * ```
535
+ */
536
+ export function useTrafficalTrack() {
537
+ const { client, getUnitKey } = useTrafficalContext();
538
+
539
+ const track = useCallback(
540
+ (
541
+ event: string,
542
+ properties?: Record<string, unknown>,
543
+ options?: { decisionId?: string }
544
+ ) => {
545
+ if (!client) {
546
+ console.warn("[Traffical] Client not initialized, cannot track event");
547
+ return;
548
+ }
549
+
550
+ client.track(event, properties, {
551
+ decisionId: options?.decisionId,
552
+ unitKey: getUnitKey(),
553
+ });
554
+ },
555
+ [client, getUnitKey]
556
+ );
557
+
558
+ return track;
559
+ }
560
+
561
+ /**
562
+ * @deprecated Use useTrafficalTrack() instead.
563
+ *
564
+ * Hook to track a reward.
565
+ *
566
+ * @example
567
+ * ```tsx
568
+ * const trackReward = useTrafficalReward();
569
+ *
570
+ * const handlePurchase = (amount: number) => {
571
+ * trackReward({
572
+ * decisionId: decision.decisionId,
573
+ * reward: amount,
574
+ * rewardType: "revenue",
575
+ * });
576
+ * };
577
+ * ```
578
+ */
579
+ export function useTrafficalReward() {
580
+ const { client, getUnitKey, getContext } = useTrafficalContext();
581
+
582
+ const trackReward = useCallback(
583
+ (options: {
584
+ decisionId: string;
585
+ reward: number;
586
+ rewardType?: string;
587
+ rewards?: Record<string, number>;
588
+ }) => {
589
+ if (!client) {
590
+ console.warn("[Traffical] Client not initialized, cannot track reward");
591
+ return;
592
+ }
593
+
594
+ // Map old API to new track() API
595
+ client.track(options.rewardType || "reward", {
596
+ value: options.reward,
597
+ ...(options.rewards ? { rewards: options.rewards } : {}),
598
+ }, {
599
+ decisionId: options.decisionId,
600
+ unitKey: getUnitKey(),
601
+ });
602
+ },
603
+ [client, getUnitKey, getContext]
604
+ );
605
+
606
+ return trackReward;
607
+ }
608
+
609
+ /**
610
+ * Hook to access a registered plugin by name.
611
+ *
612
+ * @example
613
+ * ```tsx
614
+ * import { createDOMBindingPlugin, DOMBindingPlugin } from '@traffical/js-client';
615
+ *
616
+ * // In your provider config:
617
+ * plugins: [createDOMBindingPlugin()]
618
+ *
619
+ * // In a component:
620
+ * const domPlugin = useTrafficalPlugin<DOMBindingPlugin>('dom-binding');
621
+ *
622
+ * // Re-apply bindings after dynamic content changes
623
+ * useEffect(() => {
624
+ * domPlugin?.applyBindings();
625
+ * }, [contentLoaded, domPlugin]);
626
+ * ```
627
+ */
628
+ export function useTrafficalPlugin<T extends TrafficalPlugin = TrafficalPlugin>(
629
+ name: string
630
+ ): T | undefined {
631
+ const { client, ready } = useTrafficalContext();
632
+
633
+ if (!client || !ready) {
634
+ return undefined;
635
+ }
636
+
637
+ return client.getPlugin(name) as T | undefined;
638
+ }
639
+
640
+ /**
641
+ * Hook to access the Traffical client directly.
642
+ *
643
+ * @example
644
+ * ```tsx
645
+ * const { client, ready } = useTrafficalClient();
646
+ *
647
+ * if (ready && client) {
648
+ * const version = client.getConfigVersion();
649
+ * const stableId = client.getStableId();
650
+ * }
651
+ * ```
652
+ */
653
+ export function useTrafficalClient() {
654
+ const { client, ready, error } = useTrafficalContext();
655
+ return { client, ready, error };
656
+ }