-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathFormoAnalyticsProvider.tsx
More file actions
295 lines (262 loc) · 8.99 KB
/
FormoAnalyticsProvider.tsx
File metadata and controls
295 lines (262 loc) · 8.99 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
import React, {
createContext,
useContext,
useEffect,
useMemo,
useRef,
useState,
FC,
} from "react";
import { FormoAnalytics } from "./FormoAnalytics";
import { initStorageManager } from "./lib/storage";
import { logger } from "./lib/logger";
import { FormoAnalyticsProviderProps, IFormoAnalytics } from "./types";
// Default context with no-op methods
const defaultContext: IFormoAnalytics = {
chain: () => Promise.resolve(),
screen: () => Promise.resolve(),
reset: () => {},
cleanup: () => Promise.resolve(),
detect: () => Promise.resolve(),
connect: () => Promise.resolve(),
disconnect: () => Promise.resolve(),
signature: () => Promise.resolve(),
transaction: () => Promise.resolve(),
identify: () => Promise.resolve(),
track: () => Promise.resolve(),
setTrafficSourceFromUrl: () => {},
optOutTracking: () => {},
optInTracking: () => {},
hasOptedOutTracking: () => false,
};
export const FormoAnalyticsContext =
createContext<IFormoAnalytics>(defaultContext);
/**
* Formo Analytics Provider for React Native
*
* Wraps your app to provide analytics context
*
* @example
* ```tsx
* import AsyncStorage from '@react-native-async-storage/async-storage';
* import { FormoAnalyticsProvider } from '@formo/react-native-analytics';
*
* function App() {
* return (
* <FormoAnalyticsProvider
* writeKey="your-write-key"
* asyncStorage={AsyncStorage}
* options={{ wagmi: { config, queryClient } }}
* >
* <YourApp />
* </FormoAnalyticsProvider>
* );
* }
* ```
*/
export const FormoAnalyticsProvider: FC<FormoAnalyticsProviderProps> = (
props
) => {
const { writeKey, disabled = false, children } = props;
if (!writeKey) {
logger.error("FormoAnalyticsProvider: No Write Key provided");
return (
<FormoAnalyticsContext.Provider value={defaultContext}>
{children}
</FormoAnalyticsContext.Provider>
);
}
if (disabled) {
logger.warn("FormoAnalytics is disabled");
return (
<FormoAnalyticsContext.Provider value={defaultContext}>
{children}
</FormoAnalyticsContext.Provider>
);
}
return <InitializedAnalytics {...props} />;
};
const InitializedAnalytics: FC<FormoAnalyticsProviderProps> = ({
writeKey,
options,
asyncStorage,
onReady,
onError,
children,
}) => {
const [sdk, setSdk] = useState<IFormoAnalytics>(defaultContext);
const sdkRef = useRef<IFormoAnalytics>(defaultContext);
const storageInitKeyRef = useRef<string | null>(null);
const cleanupPromiseRef = useRef<Promise<void>>(Promise.resolve());
const initializationIdRef = useRef<number>(0);
// Only initialize storage manager when writeKey changes, not on every render
if (storageInitKeyRef.current !== writeKey) {
initStorageManager(writeKey);
storageInitKeyRef.current = writeKey;
}
// Store callbacks and options in refs to avoid re-initialization when references change
// This fixes the issue where inline arrow functions cause repeated SDK init
const onReadyRef = useRef(onReady);
const onErrorRef = useRef(onError);
const optionsRef = useRef(options);
// Update refs when values change (without triggering effect)
useEffect(() => {
onReadyRef.current = onReady;
}, [onReady]);
useEffect(() => {
onErrorRef.current = onError;
}, [onError]);
useEffect(() => {
optionsRef.current = options;
}, [options]);
// Extract individual option values to avoid reference equality issues with options object
const tracking = options?.tracking;
const autocapture = options?.autocapture;
const apiHost = options?.apiHost;
const flushAt = options?.flushAt;
const flushInterval = options?.flushInterval;
const retryCount = options?.retryCount;
const maxQueueSize = options?.maxQueueSize;
const loggerOption = options?.logger;
const app = options?.app;
const hasReady = !!options?.ready;
const wagmiConfig = options?.wagmi?.config;
const wagmiQueryClient = options?.wagmi?.queryClient;
// Create stable key from serializable options
const optionsKey = useMemo(() => {
const serializableOptions = {
tracking,
autocapture,
apiHost,
flushAt,
flushInterval,
retryCount,
maxQueueSize,
logger: loggerOption,
app,
hasReady,
};
try {
return JSON.stringify(serializableOptions);
} catch (error) {
logger.warn("Failed to serialize options, using timestamp", error);
return Date.now().toString();
}
}, [tracking, autocapture, apiHost, flushAt, flushInterval, retryCount, maxQueueSize, loggerOption, app, hasReady]);
useEffect(() => {
// Increment initialization ID to track which initialization is current
const currentInitId = ++initializationIdRef.current;
let isCleanedUp = false;
const initialize = async () => {
// Wait for any pending cleanup to complete before re-initializing
// This prevents race conditions between cleanup and init
await cleanupPromiseRef.current;
// Check if this initialization is still current after awaiting cleanup
if (currentInitId !== initializationIdRef.current || isCleanedUp) {
logger.debug("Skipping stale initialization");
return;
}
// Clean up existing SDK and await flush completion
if (sdkRef.current && sdkRef.current !== defaultContext) {
logger.log("Cleaning up existing FormoAnalytics SDK instance");
const cleanup = sdkRef.current.cleanup();
cleanupPromiseRef.current = cleanup;
await cleanup;
sdkRef.current = defaultContext;
setSdk(defaultContext);
}
// Check again after cleanup
if (currentInitId !== initializationIdRef.current || isCleanedUp) {
logger.debug("Skipping stale initialization after cleanup");
return;
}
try {
// Use optionsRef.current to ensure we have the latest options
const sdkInstance = await FormoAnalytics.init(
writeKey,
optionsRef.current,
asyncStorage
);
// Verify this initialization is still current
if (currentInitId !== initializationIdRef.current || isCleanedUp) {
logger.log("Initialization superseded, cleaning up new instance");
await sdkInstance.cleanup();
return;
}
setSdk(sdkInstance);
sdkRef.current = sdkInstance;
logger.log("Successfully initialized FormoAnalytics SDK");
// Call onReady callback using the ref (stable reference)
onReadyRef.current?.(sdkInstance);
} catch (error) {
if (currentInitId === initializationIdRef.current && !isCleanedUp) {
logger.error("Failed to initialize FormoAnalytics SDK", error);
// Call onError callback using the ref (stable reference)
onErrorRef.current?.(error instanceof Error ? error : new Error(String(error)));
}
}
};
initialize();
return () => {
isCleanedUp = true;
if (sdkRef.current && sdkRef.current !== defaultContext) {
logger.log("Cleaning up FormoAnalytics SDK instance");
// Store cleanup promise so next initialization can await it
const cleanup = sdkRef.current.cleanup().catch((error) => {
logger.error("Error during SDK cleanup:", error);
});
cleanupPromiseRef.current = cleanup;
sdkRef.current = defaultContext;
}
};
// Note: onReady and onError are NOT in the dependency array
// They are accessed via refs to prevent re-initialization
// wagmiConfig and wagmiQueryClient are tracked separately since they're not serializable
}, [writeKey, optionsKey, asyncStorage, wagmiConfig, wagmiQueryClient]);
return (
<FormoAnalyticsContext.Provider value={sdk}>
{children}
</FormoAnalyticsContext.Provider>
);
};
/**
* Hook to access Formo Analytics
*
* @example
* ```tsx
* import { useFormo } from '@formo/react-native-analytics';
*
* function MyScreen() {
* const formo = useFormo();
*
* useEffect(() => {
* formo.screen('Home');
* }, []);
*
* const handleButtonPress = () => {
* formo.track('Button Pressed', { buttonName: 'signup' });
* };
*
* return <Button onPress={handleButtonPress}>Sign Up</Button>;
* }
* ```
*/
// Track if the useFormo warning has been logged to avoid console spam
let hasLoggedUseFormoWarning = false;
export const useFormo = (): IFormoAnalytics => {
const context = useContext(FormoAnalyticsContext);
// Check if SDK has been initialized (context will be defaultContext before init completes)
// Only log the warning once to avoid flooding the console during async initialization
if (context === defaultContext && !hasLoggedUseFormoWarning) {
hasLoggedUseFormoWarning = true;
logger.warn(
"useFormo called before SDK initialization complete. " +
"Ensure FormoAnalyticsProvider is mounted and writeKey is provided."
);
}
// Reset the warning flag when SDK is initialized so it can warn again after a reset
if (context !== defaultContext) {
hasLoggedUseFormoWarning = false;
}
return context;
};