diff --git a/.gitignore b/.gitignore index 50185058..6aac016b 100644 --- a/.gitignore +++ b/.gitignore @@ -64,3 +64,4 @@ claude/ .github/copilot-* .github/instructions/ .github/prompts/ +WARP.md diff --git a/FEATURE_FLAGS_JS_MODE_FINDINGS.md b/FEATURE_FLAGS_JS_MODE_FINDINGS.md new file mode 100644 index 00000000..e2725f3b --- /dev/null +++ b/FEATURE_FLAGS_JS_MODE_FINDINGS.md @@ -0,0 +1,97 @@ +# Feature Flags JavaScript Mode - Test Results & Findings + +## Summary +JavaScript mode for feature flags has been successfully enabled for testing via environment variable. The implementation is mostly working but has some async operation issues that need resolution. + +## What's Working ✅ +1. **Environment Variable Control**: `MIXPANEL_ENABLE_JS_FLAGS=true` successfully enables JavaScript mode +2. **Basic Initialization**: Mixpanel instance creates correctly in JavaScript mode +3. **Synchronous Methods**: All sync methods work as expected: + - `areFlagsReady()` + - `getVariantSync()` + - `getVariantValueSync()` + - `isEnabledSync()` +4. **Snake-case Aliases**: API compatibility methods working +5. **Error Handling**: Gracefully handles null feature names + +## Issues Found & Fixed ✅ + +### 1. Async Methods Timeout (FIXED) +The following async methods were hanging indefinitely (5+ second timeout): +- `loadFlags()` +- `getVariant()` (async version) +- `getVariantValue()` (async version) +- `isEnabled()` (async version) +- `updateContext()` + +**Root Cause**: The MixpanelNetwork.sendRequest method was: +1. Always sending POST requests, even for the flags endpoint (which should be GET) +2. Retrying all failed requests with exponential backoff (up to 5 retries) +3. For GET requests returning 404, this caused 5+ seconds of retry delays + +**Solution**: Modified `javascript/mixpanel-network.js`: +- Detect GET requests (when data is null/undefined) +- Send proper GET requests without body for flags endpoint +- Don't retry GET requests on client errors (4xx status codes) +- Only retry POST requests or server errors (5xx) + +### 2. Test Suite Hanging (RESOLVED) +- **Initial Issue**: Tests would not exit after completion +- **Cause**: Recurring intervals from `mixpanel-core.js` queue processing +- **Solution**: Removed fake timers and added proper cleanup in `afterEach` + +## Code Changes Made + +### 1. index.js (Lines 89-95) +```javascript +// Enable JS flags for testing via environment variable +const jsFlagesEnabled = process.env.MIXPANEL_ENABLE_JS_FLAGS === 'true' || + process.env.NODE_ENV === 'test'; + +// Short circuit for JavaScript mode unless explicitly enabled +if (this.mixpanelImpl !== MixpanelReactNative && !jsFlagesEnabled) { + throw new Error( + "Feature flags are only available in native mode. " + + "JavaScript mode support is coming in a future release." + ); +} +``` + +### 2. Test File Created +- Created `__tests__/flags-js-mode.test.js` with comprehensive JavaScript mode tests +- Tests pass AsyncStorage mock as 4th parameter to Mixpanel constructor +- Proper cleanup to prevent hanging + +## Next Steps + +### Immediate (Before Beta Release) +1. ✅ **Fix Async Methods**: COMPLETE - Fixed network layer to handle GET requests properly +2. **Test in Real Expo App**: Run in actual Expo environment (not just unit tests) +3. **Performance Testing**: Verify AsyncStorage performance with large flag sets + +### Future Enhancements +1. **Remove Blocking Check**: Once stable, remove environment variable requirement +2. **Documentation**: Update FEATURE_FLAGS_QUICKSTART.md with JS mode examples +3. **Migration Guide**: Document differences between native and JS modes + +## Testing Commands + +```bash +# Run JavaScript mode tests +MIXPANEL_ENABLE_JS_FLAGS=true npm test -- --testPathPattern=flags-js-mode + +# Run in Expo app +cd Samples/MixpanelExpo +MIXPANEL_ENABLE_JS_FLAGS=true npm start +``` + +## Risk Assessment +- **Low Risk**: Core functionality works, follows established patterns +- **Low Risk**: All async operations now working correctly +- **Mitigation**: Keep behind environment variable until Expo testing complete + +## Recommendations +1. ✅ Async methods fixed - ready for beta testing +2. Test in real Expo environment before removing environment variable guard +3. Consider adding a `jsMode` flag to initialization options for cleaner API +4. Monitor network performance with real API endpoints \ No newline at end of file diff --git a/Samples/MixpanelExpo/.env b/Samples/MixpanelExpo/.env new file mode 100644 index 00000000..7eff781c --- /dev/null +++ b/Samples/MixpanelExpo/.env @@ -0,0 +1,5 @@ +# Enable JavaScript mode feature flags for testing +MIXPANEL_ENABLE_JS_FLAGS=true + +# Replace with your actual Mixpanel project token +MIXPANEL_TOKEN="metrics-1" diff --git a/Samples/MixpanelExpo/App.js b/Samples/MixpanelExpo/App.js index d2c7566c..39981228 100644 --- a/Samples/MixpanelExpo/App.js +++ b/Samples/MixpanelExpo/App.js @@ -1,4 +1,4 @@ -import React from "react"; +import React, { useState, useEffect, useRef } from "react"; import { SectionList, Text, @@ -6,20 +6,51 @@ import { Button, StyleSheet, SafeAreaView, + ActivityIndicator, } from "react-native"; import { Mixpanel } from "mixpanel-react-native"; +import AsyncStorage from "@react-native-async-storage/async-storage"; +import { MIXPANEL_TOKEN } from "@env"; const App = () => { - const trackAutomaticEvents = false; - const useNative = false; - const mixpanel = new Mixpanel( - "YOUR_MIXPANEL_TOKEN", - trackAutomaticEvents, - useNative - ); - mixpanel.init(); - mixpanel.setLoggingEnabled(true); + const [isInitialized, setIsInitialized] = useState(false); + const mixpanelRef = useRef(null); + + // Test flag name - replace with your actual flag from Mixpanel + const testFlagName = "sample-bool-flag"; + + useEffect(() => { + const initMixpanel = async () => { + const trackAutomaticEvents = false; + const useNative = false; + // Pass AsyncStorage for JavaScript mode feature flags support + const mp = new Mixpanel(MIXPANEL_TOKEN, trackAutomaticEvents, useNative, AsyncStorage); + + // Enable feature flags during initialization + await mp.init(false, {}, undefined, false, { enabled: true }); + mp.setLoggingEnabled(true); + + mixpanelRef.current = mp; + setIsInitialized(true); + console.log("[Mixpanel] Initialized with token:", MIXPANEL_TOKEN); + }; + + initMixpanel(); + }, []); + + // Helper to get mixpanel instance + const mixpanel = mixpanelRef.current; + + // Show loading while initializing + if (!isInitialized || !mixpanel) { + return ( + + + Initializing Mixpanel... + + ); + } const group = mixpanel.getGroup("company_id", 111); const track = async () => { @@ -197,6 +228,97 @@ const App = () => { ); }; + // ----------------- Feature Flags API ----------------- + const loadFlags = async () => { + try { + await mixpanel.flags.loadFlags(); + alert("Flags loaded successfully!"); + } catch (error) { + alert(`Failed to load flags: ${error.message}`); + } + }; + + const checkFlagsReady = () => { + const ready = mixpanel.flags.areFlagsReady(); + alert(`Flags ready: ${ready}`); + }; + + const getVariantSync = () => { + const fallback = { key: "fallback", value: null }; + try { + const result = mixpanel.flags.getVariantSync(testFlagName, fallback); + alert( + `getVariantSync('${testFlagName}'):\n` + + `Key: ${result.key}\n` + + `Value: ${JSON.stringify(result.value)}\n` + + `Experiment ID: ${result.experiment_id || "N/A"}` + ); + } catch (error) { + alert(`Error: ${error.message}`); + } + }; + + const getVariantValueSync = () => { + const fallback = "default-value"; + try { + const result = mixpanel.flags.getVariantValueSync(testFlagName, fallback); + alert( + `getVariantValueSync('${testFlagName}'):\n` + + `Value: ${JSON.stringify(result)}\n` + + `Type: ${typeof result}` + ); + } catch (error) { + alert(`Error: ${error.message}`); + } + }; + + const isEnabledSync = () => { + try { + const result = mixpanel.flags.isEnabledSync(testFlagName, false); + alert(`isEnabledSync('${testFlagName}'): ${result}`); + } catch (error) { + alert(`Error: ${error.message}`); + } + }; + + const getVariantAsync = async () => { + const fallback = { key: "fallback", value: null }; + try { + const result = await mixpanel.flags.getVariant(testFlagName, fallback); + alert( + `getVariant('${testFlagName}') [async]:\n` + + `Key: ${result.key}\n` + + `Value: ${JSON.stringify(result.value)}\n` + + `Experiment ID: ${result.experiment_id || "N/A"}` + ); + } catch (error) { + alert(`Error: ${error.message}`); + } + }; + + const getVariantValueAsync = async () => { + const fallback = "default-value"; + try { + const result = await mixpanel.flags.getVariantValue(testFlagName, fallback); + alert( + `getVariantValue('${testFlagName}') [async]:\n` + + `Value: ${JSON.stringify(result)}\n` + + `Type: ${typeof result}` + ); + } catch (error) { + alert(`Error: ${error.message}`); + } + }; + + const isEnabledAsync = async () => { + try { + const result = await mixpanel.flags.isEnabled(testFlagName, false); + alert(`isEnabled('${testFlagName}') [async]: ${result}`); + } catch (error) { + alert(`Error: ${error.message}`); + } + }; + const DATA = [ { title: "Events and Properties", @@ -296,6 +418,20 @@ const App = () => { { id: "11", label: "Flush", onPress: flush }, ], }, + { + title: "Feature Flags", + data: [ + { id: "1", label: "Load Flags", onPress: loadFlags }, + { id: "2", label: "Check Flags Ready", onPress: checkFlagsReady }, + { id: "3", label: "getVariantSync()", onPress: getVariantSync }, + { id: "4", label: "getVariantValueSync()", onPress: getVariantValueSync }, + { id: "5", label: "isEnabledSync()", onPress: isEnabledSync }, + { id: "6", label: "getVariant() [async]", onPress: getVariantAsync }, + { id: "7", label: "getVariantValue() [async]", onPress: getVariantValueAsync }, + { id: "8", label: "isEnabled() [async]", onPress: isEnabledAsync }, + { id: "9", label: "Flush", onPress: flush }, + ], + }, ]; const renderItem = ({ item }) => ( @@ -324,6 +460,17 @@ const styles = StyleSheet.create({ container: { flex: 1, }, + loadingContainer: { + flex: 1, + justifyContent: "center", + alignItems: "center", + backgroundColor: "#fff", + }, + loadingText: { + marginTop: 16, + fontSize: 16, + color: "#666", + }, header: { fontSize: 20, backgroundColor: "#f4f4f4", diff --git a/Samples/MixpanelExpo/babel.config.js b/Samples/MixpanelExpo/babel.config.js index 2900afe9..cd17a170 100644 --- a/Samples/MixpanelExpo/babel.config.js +++ b/Samples/MixpanelExpo/babel.config.js @@ -2,5 +2,13 @@ module.exports = function(api) { api.cache(true); return { presets: ['babel-preset-expo'], + plugins: [ + ['module:react-native-dotenv', { + moduleName: '@env', + path: '.env', + safe: false, + allowUndefined: true, + }], + ], }; }; diff --git a/Samples/MixpanelExpo/package-lock.json b/Samples/MixpanelExpo/package-lock.json index 86475676..df724521 100644 --- a/Samples/MixpanelExpo/package-lock.json +++ b/Samples/MixpanelExpo/package-lock.json @@ -44,11 +44,11 @@ } }, ".yalc/mixpanel-react-native": { - "version": "3.0.9", + "version": "3.2.0-beta.2", "license": "Apache-2.0", "dependencies": { "@react-native-async-storage/async-storage": "^1.21.0", - "expo-crypto": "~13.0.2", + "react-native-get-random-values": "^1.9.0", "uuid": "3.3.2" } }, @@ -98,6 +98,7 @@ "version": "7.24.0", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz", "integrity": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==", + "peer": true, "dependencies": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.23.5", @@ -1816,6 +1817,7 @@ "version": "7.24.0", "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.24.0.tgz", "integrity": "sha512-ZxPEzV9IgvGn73iK0E6VB9/95Nd7aMFpbE0l8KQFDG70cOV9IxRP7Y2FUPmlK0v6ImlLqYX50iuZ3ZTVhOF2lA==", + "peer": true, "dependencies": { "@babel/compat-data": "^7.23.5", "@babel/helper-compilation-targets": "^7.23.6", @@ -5513,6 +5515,7 @@ "version": "0.73.21", "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.73.21.tgz", "integrity": "sha512-WlFttNnySKQMeujN09fRmrdWqh46QyJluM5jdtDNrkl/2Hx6N4XeDUGhABvConeK95OidVO7sFFf7sNebVXogA==", + "peer": true, "dependencies": { "@babel/core": "^7.20.0", "@babel/plugin-proposal-async-generator-functions": "^7.0.0", @@ -6547,6 +6550,7 @@ "url": "https://github.com/sponsors/ai" } ], + "peer": true, "dependencies": { "caniuse-lite": "^1.0.30001587", "electron-to-chromium": "^1.4.668", @@ -7542,6 +7546,7 @@ "version": "50.0.8", "resolved": "https://registry.npmjs.org/expo/-/expo-50.0.8.tgz", "integrity": "sha512-8yXsoMbFRjWyEDNuFRtH0vTFvEjFnnwP+LceS6xmXGp+IW1hKdN1X6Bj1EUocFtepH0ruHDPCof1KvPoWfUWkw==", + "peer": true, "dependencies": { "@babel/runtime": "^7.20.0", "@expo/cli": "0.17.6", @@ -7587,18 +7592,6 @@ "expo": "*" } }, - "node_modules/expo-crypto": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-13.0.2.tgz", - "integrity": "sha512-7f/IMPYJZkBM21LNEMXGrNo/0uXSVfZTwufUdpNKedJR0fm5fH4DCSN79ZddlV26nF90PuXjK2inIbI6lb0qRA==", - "license": "MIT", - "dependencies": { - "base64-js": "^1.3.0" - }, - "peerDependencies": { - "expo": "*" - } - }, "node_modules/expo-file-system": { "version": "16.0.7", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-16.0.7.tgz", @@ -7630,6 +7623,7 @@ "version": "1.10.3", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-1.10.3.tgz", "integrity": "sha512-pn4n2Dl4iRh/zUeiChjRIe1C7EqOw1qhccr85viQV7W6l5vgRpY0osE51ij5LKg/kJmGRcJfs12+PwbdTplbKw==", + "peer": true, "dependencies": { "@expo/config": "~8.5.0", "chalk": "^4.1.0", @@ -7752,6 +7746,12 @@ "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-1.11.1.tgz", "integrity": "sha512-ddQEtCOgYHTLlFUe/yH67dDBIoct5VIULthyT3LRJbEwdpzAgueKsX2FYK02ldh440V87PWKCamh7R9evk1rrg==" }, + "node_modules/fast-base64-decode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-base64-decode/-/fast-base64-decode-1.0.0.tgz", + "integrity": "sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q==", + "license": "MIT" + }, "node_modules/fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -8124,6 +8124,7 @@ "version": "15.8.0", "resolved": "https://registry.npmjs.org/graphql/-/graphql-15.8.0.tgz", "integrity": "sha512-5gghUc24tP9HRznNpV2+FIoq3xKkj5dTQqf4v0CpdPbFVwFkWoxOM+o+2OC9ZSvjEMTjfmG9QT+gcvggTwW1zw==", + "peer": true, "engines": { "node": ">= 10.x" } @@ -11353,6 +11354,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "peer": true, "dependencies": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -11370,6 +11372,7 @@ "version": "0.73.4", "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.73.4.tgz", "integrity": "sha512-VtS+Yr6OOTIuJGDECIYWzNU8QpJjASQYvMtfa/Hvm/2/h5GdB6W9H9TOmh13x07Lj4AOhNMx3XSsz6TdrO4jIg==", + "peer": true, "dependencies": { "@jest/create-cache-key-function": "^29.6.3", "@react-native-community/cli": "12.3.2", @@ -11420,6 +11423,18 @@ "react": "18.2.0" } }, + "node_modules/react-native-get-random-values": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-1.11.0.tgz", + "integrity": "sha512-4BTbDbRmS7iPdhYLRcz3PGFIpFJBwNZg9g42iwa2P6FOv9vZj/xJc678RZXnLNZzd0qd7Q3CCF6Yd+CU2eoXKQ==", + "license": "MIT", + "dependencies": { + "fast-base64-decode": "^1.0.0" + }, + "peerDependencies": { + "react-native": ">=0.56" + } + }, "node_modules/react-native-web": { "version": "0.19.10", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.10.tgz", @@ -13135,6 +13150,7 @@ "version": "7.24.0", "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.24.0.tgz", "integrity": "sha512-fQfkg0Gjkza3nf0c7/w6Xf34BW4YvzNfACRLmmb7XRLa6XHdR+K9AlJlxneFfWYf6uhOzuzZVTjF/8KfndZANw==", + "peer": true, "requires": { "@ampproject/remapping": "^2.2.0", "@babel/code-frame": "^7.23.5", @@ -14219,6 +14235,7 @@ "version": "7.24.0", "resolved": "https://registry.npmjs.org/@babel/preset-env/-/preset-env-7.24.0.tgz", "integrity": "sha512-ZxPEzV9IgvGn73iK0E6VB9/95Nd7aMFpbE0l8KQFDG70cOV9IxRP7Y2FUPmlK0v6ImlLqYX50iuZ3ZTVhOF2lA==", + "peer": true, "requires": { "@babel/compat-data": "^7.23.5", "@babel/helper-compilation-targets": "^7.23.6", @@ -16959,6 +16976,7 @@ "version": "0.73.21", "resolved": "https://registry.npmjs.org/@react-native/babel-preset/-/babel-preset-0.73.21.tgz", "integrity": "sha512-WlFttNnySKQMeujN09fRmrdWqh46QyJluM5jdtDNrkl/2Hx6N4XeDUGhABvConeK95OidVO7sFFf7sNebVXogA==", + "peer": true, "requires": { "@babel/core": "^7.20.0", "@babel/plugin-proposal-async-generator-functions": "^7.0.0", @@ -17761,6 +17779,7 @@ "version": "4.23.0", "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.23.0.tgz", "integrity": "sha512-QW8HiM1shhT2GuzkvklfjcKDiWFXHOeFCIA/huJPwHsslwcydgk7X+z2zXpEijP98UCY7HbubZt5J2Zgvf0CaQ==", + "peer": true, "requires": { "caniuse-lite": "^1.0.30001587", "electron-to-chromium": "^1.4.668", @@ -18486,6 +18505,7 @@ "version": "50.0.8", "resolved": "https://registry.npmjs.org/expo/-/expo-50.0.8.tgz", "integrity": "sha512-8yXsoMbFRjWyEDNuFRtH0vTFvEjFnnwP+LceS6xmXGp+IW1hKdN1X6Bj1EUocFtepH0ruHDPCof1KvPoWfUWkw==", + "peer": true, "requires": { "@babel/runtime": "^7.20.0", "@expo/cli": "0.17.6", @@ -18525,14 +18545,6 @@ "@expo/config": "~8.5.0" } }, - "expo-crypto": { - "version": "13.0.2", - "resolved": "https://registry.npmjs.org/expo-crypto/-/expo-crypto-13.0.2.tgz", - "integrity": "sha512-7f/IMPYJZkBM21LNEMXGrNo/0uXSVfZTwufUdpNKedJR0fm5fH4DCSN79ZddlV26nF90PuXjK2inIbI6lb0qRA==", - "requires": { - "base64-js": "^1.3.0" - } - }, "expo-file-system": { "version": "16.0.7", "resolved": "https://registry.npmjs.org/expo-file-system/-/expo-file-system-16.0.7.tgz", @@ -18557,6 +18569,7 @@ "version": "1.10.3", "resolved": "https://registry.npmjs.org/expo-modules-autolinking/-/expo-modules-autolinking-1.10.3.tgz", "integrity": "sha512-pn4n2Dl4iRh/zUeiChjRIe1C7EqOw1qhccr85viQV7W6l5vgRpY0osE51ij5LKg/kJmGRcJfs12+PwbdTplbKw==", + "peer": true, "requires": { "@expo/config": "~8.5.0", "chalk": "^4.1.0", @@ -18649,6 +18662,11 @@ "resolved": "https://registry.npmjs.org/expo-status-bar/-/expo-status-bar-1.11.1.tgz", "integrity": "sha512-ddQEtCOgYHTLlFUe/yH67dDBIoct5VIULthyT3LRJbEwdpzAgueKsX2FYK02ldh440V87PWKCamh7R9evk1rrg==" }, + "fast-base64-decode": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fast-base64-decode/-/fast-base64-decode-1.0.0.tgz", + "integrity": "sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q==" + }, "fast-glob": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz", @@ -18930,7 +18948,8 @@ "graphql": { "version": "15.8.0", "resolved": "https://registry.npmjs.org/graphql/-/graphql-15.8.0.tgz", - "integrity": "sha512-5gghUc24tP9HRznNpV2+FIoq3xKkj5dTQqf4v0CpdPbFVwFkWoxOM+o+2OC9ZSvjEMTjfmG9QT+gcvggTwW1zw==" + "integrity": "sha512-5gghUc24tP9HRznNpV2+FIoq3xKkj5dTQqf4v0CpdPbFVwFkWoxOM+o+2OC9ZSvjEMTjfmG9QT+gcvggTwW1zw==", + "peer": true }, "graphql-tag": { "version": "2.12.6", @@ -20563,7 +20582,7 @@ "version": "file:.yalc/mixpanel-react-native", "requires": { "@react-native-async-storage/async-storage": "^1.21.0", - "expo-crypto": "~13.0.2", + "react-native-get-random-values": "^1.9.0", "uuid": "3.3.2" }, "dependencies": { @@ -21271,6 +21290,7 @@ "version": "18.2.0", "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.2.0.tgz", "integrity": "sha512-6IMTriUmvsjHUjNtEDudZfuDQUoWXVxKHhlEGSk81n4YFS+r/Kl99wXiwlVXtPBtJenozv2P+hxDsw9eA7Xo6g==", + "peer": true, "requires": { "loose-envify": "^1.1.0", "scheduler": "^0.23.0" @@ -21285,6 +21305,7 @@ "version": "0.73.4", "resolved": "https://registry.npmjs.org/react-native/-/react-native-0.73.4.tgz", "integrity": "sha512-VtS+Yr6OOTIuJGDECIYWzNU8QpJjASQYvMtfa/Hvm/2/h5GdB6W9H9TOmh13x07Lj4AOhNMx3XSsz6TdrO4jIg==", + "peer": true, "requires": { "@jest/create-cache-key-function": "^29.6.3", "@react-native-community/cli": "12.3.2", @@ -21400,6 +21421,14 @@ } } }, + "react-native-get-random-values": { + "version": "1.11.0", + "resolved": "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-1.11.0.tgz", + "integrity": "sha512-4BTbDbRmS7iPdhYLRcz3PGFIpFJBwNZg9g42iwa2P6FOv9vZj/xJc678RZXnLNZzd0qd7Q3CCF6Yd+CU2eoXKQ==", + "requires": { + "fast-base64-decode": "^1.0.0" + } + }, "react-native-web": { "version": "0.19.10", "resolved": "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.10.tgz", diff --git a/Samples/MixpanelExpo/yarn.lock b/Samples/MixpanelExpo/yarn.lock index 83a1159f..f86e4227 100644 --- a/Samples/MixpanelExpo/yarn.lock +++ b/Samples/MixpanelExpo/yarn.lock @@ -2402,7 +2402,7 @@ balanced-match@^1.0.0: resolved "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz" integrity sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw== -base64-js@^1.2.3, base64-js@^1.3.0, base64-js@^1.3.1, base64-js@^1.5.1: +base64-js@^1.2.3, base64-js@^1.3.1, base64-js@^1.5.1: version "1.5.1" resolved "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== @@ -3216,13 +3216,6 @@ expo-constants@~15.4.0: dependencies: "@expo/config" "~8.5.0" -expo-crypto@~13.0.2: - version "13.0.2" - resolved "https://registry.npmjs.org/expo-crypto/-/expo-crypto-13.0.2.tgz" - integrity sha512-7f/IMPYJZkBM21LNEMXGrNo/0uXSVfZTwufUdpNKedJR0fm5fH4DCSN79ZddlV26nF90PuXjK2inIbI6lb0qRA== - dependencies: - base64-js "^1.3.0" - expo-file-system@~16.0.0, expo-file-system@~16.0.7: version "16.0.7" resolved "https://registry.npmjs.org/expo-file-system/-/expo-file-system-16.0.7.tgz" @@ -3285,6 +3278,11 @@ expo@*, expo@~50.0.8: fbemitter "^3.0.0" whatwg-url-without-unicode "8.0.0-3" +fast-base64-decode@^1.0.0: + version "1.0.0" + resolved "https://registry.npmjs.org/fast-base64-decode/-/fast-base64-decode-1.0.0.tgz" + integrity sha512-qwaScUgUGBYeDNRnbc/KyllVU88Jk1pRHPStuF/lO7B0/RTRLj7U0lkdTAutlBblY08rwZDff6tNU9cjv6j//Q== + fast-glob@^3.2.5, fast-glob@^3.2.9: version "3.3.2" resolved "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.2.tgz" @@ -4635,11 +4633,11 @@ minizlib@^2.1.1: yallist "^4.0.0" "mixpanel-react-native@file:.yalc/mixpanel-react-native": - version "3.0.9" + version "3.2.0-beta.2" resolved "file:.yalc/mixpanel-react-native" dependencies: "@react-native-async-storage/async-storage" "^1.21.0" - expo-crypto "~13.0.2" + react-native-get-random-values "^1.9.0" uuid "3.3.2" mkdirp@^0.5.1, mkdirp@~0.5.1: @@ -5257,6 +5255,13 @@ react-is@^18.0.0: resolved "https://registry.npmjs.org/react-is/-/react-is-18.2.0.tgz" integrity sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w== +react-native-get-random-values@^1.9.0: + version "1.11.0" + resolved "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-1.11.0.tgz" + integrity sha512-4BTbDbRmS7iPdhYLRcz3PGFIpFJBwNZg9g42iwa2P6FOv9vZj/xJc678RZXnLNZzd0qd7Q3CCF6Yd+CU2eoXKQ== + dependencies: + fast-base64-decode "^1.0.0" + react-native-web@~0.19.6: version "0.19.10" resolved "https://registry.npmjs.org/react-native-web/-/react-native-web-0.19.10.tgz" @@ -5271,7 +5276,7 @@ react-native-web@~0.19.6: postcss-value-parser "^4.2.0" styleq "^0.1.3" -react-native@*, "react-native@^0.0.0-0 || >=0.60 <1.0", react-native@0.73.4: +react-native@*, "react-native@^0.0.0-0 || >=0.60 <1.0", react-native@>=0.56, react-native@0.73.4: version "0.73.4" resolved "https://registry.npmjs.org/react-native/-/react-native-0.73.4.tgz" integrity sha512-VtS+Yr6OOTIuJGDECIYWzNU8QpJjASQYvMtfa/Hvm/2/h5GdB6W9H9TOmh13x07Lj4AOhNMx3XSsz6TdrO4jIg== diff --git a/__tests__/flags-js-mode.test.js b/__tests__/flags-js-mode.test.js new file mode 100644 index 00000000..e43638a5 --- /dev/null +++ b/__tests__/flags-js-mode.test.js @@ -0,0 +1,235 @@ +/** + * Tests for JavaScript mode feature flags functionality + */ + +// Enable JavaScript mode via environment variable +process.env.MIXPANEL_ENABLE_JS_FLAGS = 'true'; + +// Mock React Native to simulate JavaScript mode (no native modules) +jest.mock('react-native', () => ({ + NativeModules: {}, // Empty to simulate no native modules + Platform: { OS: 'ios' }, + NativeEventEmitter: jest.fn() +})); + +// Mock AsyncStorage +const mockAsyncStorage = { + getItem: jest.fn(() => Promise.resolve(null)), + setItem: jest.fn(() => Promise.resolve()), + removeItem: jest.fn(() => Promise.resolve()), + getAllKeys: jest.fn(() => Promise.resolve([])), + multiGet: jest.fn(() => Promise.resolve([])), + multiSet: jest.fn(() => Promise.resolve()), + multiRemove: jest.fn(() => Promise.resolve()) +}; + +jest.mock('@react-native-async-storage/async-storage', () => mockAsyncStorage); + +// Mock fetch for network requests +global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + status: 404, + json: () => Promise.resolve({ error: 'Not found' }) + }) +); + +// Don't use fake timers - we'll handle cleanup manually + +const { Mixpanel } = require('../index'); + +describe('Feature Flags - JavaScript Mode', () => { + let mixpanel; + + beforeEach(() => { + // Clear all mocks + jest.clearAllMocks(); + }); + + afterEach(() => { + // Clean up to prevent hanging + if (mixpanel) { + // Call reset to clean up any pending operations + if (mixpanel.mixpanelImpl && mixpanel.mixpanelImpl.reset) { + mixpanel.mixpanelImpl.reset(mixpanel.token); + } + } + mixpanel = null; + }); + + describe('JavaScript Mode Initialization', () => { + it('should create Mixpanel instance in JavaScript mode', () => { + // Pass AsyncStorage as the 4th parameter for JavaScript mode + mixpanel = new Mixpanel('js-test-token', false, false, mockAsyncStorage); + + // Verify we're NOT using native module + expect(mixpanel.mixpanelImpl.constructor.name).not.toBe('MixpanelReactNative'); + }); + + it('should initialize with feature flags enabled', async () => { + mixpanel = new Mixpanel('js-test-token', false, false, mockAsyncStorage); + + // init doesn't return a value, just await it + await mixpanel.init(false, {}, 'https://api.mixpanel.com', false, { + enabled: true, + context: { + user_type: 'tester' + } + }); + + // Check that flags property is accessible + expect(mixpanel.flags).toBeDefined(); + }); + + it('should access flags property without error in JavaScript mode', async () => { + mixpanel = new Mixpanel('js-test-token', false, false, mockAsyncStorage); + + await mixpanel.init(false, {}, 'https://api.mixpanel.com', false, { + enabled: true + }); + + // This should not throw an error with JavaScript mode enabled + expect(() => mixpanel.flags).not.toThrow(); + }); + }); + + describe('JavaScript Mode Flag Methods', () => { + beforeEach(async () => { + mixpanel = new Mixpanel('js-test-token', false, false, mockAsyncStorage); + await mixpanel.init(false, {}, 'https://api.mixpanel.com', false, { + enabled: true + }); + }); + + describe('Synchronous Methods', () => { + it('should handle areFlagsReady', () => { + const ready = mixpanel.flags.areFlagsReady(); + expect(typeof ready).toBe('boolean'); + }); + + it('should return fallback from getVariantSync', () => { + const variant = mixpanel.flags.getVariantSync('test-flag', 'fallback-value'); + expect(variant).toBe('fallback-value'); + }); + + it('should return fallback from getVariantValueSync', () => { + const value = mixpanel.flags.getVariantValueSync('button-color', 'blue'); + expect(value).toBe('blue'); + }); + + it('should return fallback from isEnabledSync', () => { + const enabled = mixpanel.flags.isEnabledSync('new-feature', false); + expect(enabled).toBe(false); + }); + }); + + describe('Asynchronous Methods', () => { + it('should handle loadFlags gracefully', async () => { + // loadFlags will fail in test environment (no real API) + // but the method should exist and be callable + expect(typeof mixpanel.flags.loadFlags).toBe('function'); + + // Call it and let it fail gracefully (network error is expected) + try { + await mixpanel.flags.loadFlags(); + } catch (error) { + // This is expected in test environment + expect(error).toBeDefined(); + } + }); + + it('should return fallback from getVariant', async () => { + const variant = await mixpanel.flags.getVariant('async-test', 'async-fallback'); + expect(variant).toBe('async-fallback'); + }); + + it('should return fallback from getVariantValue', async () => { + const value = await mixpanel.flags.getVariantValue('async-color', 'red'); + expect(value).toBe('red'); + }); + + it('should return fallback from isEnabled', async () => { + const enabled = await mixpanel.flags.isEnabled('async-feature', true); + expect(enabled).toBe(true); + }); + + it('should support callback pattern', (done) => { + mixpanel.flags.getVariant('callback-test', 'callback-fallback', (variant) => { + expect(variant).toBe('callback-fallback'); + done(); + }); + }); + }); + + describe('JavaScript-Specific Features', () => { + it('should support updateContext method', async () => { + // updateContext is JavaScript mode only + expect(typeof mixpanel.flags.updateContext).toBe('function'); + + // Call it - it should work in JS mode + await mixpanel.flags.updateContext({ + user_type: 'premium', + plan: 'enterprise' + }); + + // Verify the context was updated + expect(mixpanel.flags.jsFlags.context).toEqual({ + user_type: 'premium', + plan: 'enterprise' + }); + }); + + it('should support snake_case aliases', () => { + expect(typeof mixpanel.flags.are_flags_ready).toBe('function'); + expect(typeof mixpanel.flags.get_variant_sync).toBe('function'); + expect(typeof mixpanel.flags.get_variant_value_sync).toBe('function'); + expect(typeof mixpanel.flags.is_enabled_sync).toBe('function'); + }); + }); + }); + + describe('Error Handling', () => { + beforeEach(async () => { + mixpanel = new Mixpanel('js-test-token', false, false, mockAsyncStorage); + await mixpanel.init(false, {}, 'https://api.mixpanel.com', false, { + enabled: true + }); + }); + + it('should handle null feature names gracefully', () => { + expect(() => mixpanel.flags.getVariantSync(null, 'fallback')).not.toThrow(); + const result = mixpanel.flags.getVariantSync(null, 'fallback'); + expect(result).toBe('fallback'); + }); + + it('should handle undefined callbacks', async () => { + await expect( + mixpanel.flags.getVariant('test', 'fallback', undefined) + ).resolves.not.toThrow(); + }); + }); + + describe('Type Preservation', () => { + beforeEach(async () => { + mixpanel = new Mixpanel('js-test-token', false, false, mockAsyncStorage); + await mixpanel.init(false, {}, 'https://api.mixpanel.com', false, { + enabled: true + }); + }); + + it('should preserve boolean types', async () => { + const boolValue = await mixpanel.flags.getVariantValue('bool-flag', true); + expect(typeof boolValue).toBe('boolean'); + }); + + it('should preserve number types', async () => { + const numValue = await mixpanel.flags.getVariantValue('num-flag', 42); + expect(typeof numValue).toBe('number'); + }); + + it('should preserve object types', async () => { + const objValue = await mixpanel.flags.getVariantValue('obj-flag', { key: 'value' }); + expect(typeof objValue).toBe('object'); + }); + }); +}); \ No newline at end of file diff --git a/javascript/mixpanel-network.js b/javascript/mixpanel-network.js index d2abbd99..ecb18f71 100644 --- a/javascript/mixpanel-network.js +++ b/javascript/mixpanel-network.js @@ -15,37 +15,77 @@ export const MixpanelNetwork = (() => { serverURL, useIPAddressForGeoLocation, retryCount = 0, + headers = {}, }) => { retryCount = retryCount || 0; - const url = `${serverURL}${endpoint}?ip=${+useIPAddressForGeoLocation}`; + // Use & if endpoint already has query params, otherwise use ? + const separator = endpoint.includes('?') ? '&' : '?'; + const url = `${serverURL}${endpoint}${separator}ip=${+useIPAddressForGeoLocation}`; MixpanelLogger.log(token, `Sending request to: ${url}`); try { - const response = await fetch(url, { - method: "POST", - headers: { - "Content-Type": "application/x-www-form-urlencoded", - }, - body: `data=${encodeURIComponent(JSON.stringify(data))}`, - }); + // Determine if this is a GET or POST request based on data presence + const isGetRequest = data === null || data === undefined; - const responseBody = await response.json(); - if (response.status !== 200) { - throw new MixpanelHttpError( - `HTTP error! status: ${response.status}`, - response.status - ); - } + const fetchOptions = isGetRequest + ? { + method: "GET", + headers: headers, + } + : { + method: "POST", + headers: { + "Content-Type": "application/x-www-form-urlencoded", + ...headers, + }, + body: `data=${encodeURIComponent(JSON.stringify(data))}`, + }; + + const response = await fetch(url, fetchOptions); + + // Handle GET requests differently - they return the data directly + if (isGetRequest) { + if (response.status === 200) { + const responseData = await response.json(); + MixpanelLogger.log(token, `GET request successful: ${endpoint}`); + return responseData; + } else { + throw new MixpanelHttpError( + `HTTP error! status: ${response.status}`, + response.status + ); + } + } else { + // Handle POST requests (existing logic) + const responseBody = await response.json(); + if (response.status !== 200) { + throw new MixpanelHttpError( + `HTTP error! status: ${response.status}`, + response.status + ); + } - const message = - responseBody === 0 - ? `${url} api rejected some items` - : `Mixpanel batch sent successfully, endpoint: ${endpoint}, data: ${JSON.stringify( - data - )}`; + const message = + responseBody === 0 + ? `${url} api rejected some items` + : `Mixpanel batch sent successfully, endpoint: ${endpoint}, data: ${JSON.stringify( + data + )}`; - MixpanelLogger.log(token, message); + MixpanelLogger.log(token, message); + return responseBody; + } } catch (error) { + // Determine if this is a GET or POST request + const isGetRequest = data === null || data === undefined; + + // For GET requests (like flags), don't retry on 404 or other client errors + if (isGetRequest && error.code >= 400 && error.code < 500) { + MixpanelLogger.log(token, `GET request failed with status ${error.code}, not retrying`); + throw error; + } + + // For POST requests or non-client errors, handle retries if (error.code === 400) { // This indicates that the data was invalid and we should not retry throw new MixpanelHttpError( @@ -53,30 +93,35 @@ export const MixpanelNetwork = (() => { error.code ); } + MixpanelLogger.warn( token, `API request to ${url} has failed with reason: ${error.message}` ); - const maxRetries = 5; - const backoff = Math.min(2 ** retryCount * 2000, 60000); // Exponential backoff - if (retryCount < maxRetries) { - MixpanelLogger.log(token, `Retrying in ${backoff / 1000} seconds...`); - await new Promise((resolve) => setTimeout(resolve, backoff)); - return sendRequest({ - token, - endpoint, - data, - serverURL, - useIPAddressForGeoLocation, - retryCount: retryCount + 1, - }); - } else { - MixpanelLogger.warn(token, `Max retries reached. Giving up.`); - throw new MixpanelHttpError( - `HTTP error! status: ${error.code}`, - error.code - ); + + // Only retry for POST requests or server errors + if (!isGetRequest || error.code >= 500) { + const maxRetries = 5; + const backoff = Math.min(2 ** retryCount * 2000, 60000); // Exponential backoff + if (retryCount < maxRetries) { + MixpanelLogger.log(token, `Retrying in ${backoff / 1000} seconds...`); + await new Promise((resolve) => setTimeout(resolve, backoff)); + return sendRequest({ + token, + endpoint, + data, + serverURL, + useIPAddressForGeoLocation, + retryCount: retryCount + 1, + }); + } } + + MixpanelLogger.warn(token, `Request failed. Not retrying.`); + throw new MixpanelHttpError( + `HTTP error! status: ${error.code || 'unknown'}`, + error.code + ); } }; diff --git a/javascript/mixpanel-persistent.js b/javascript/mixpanel-persistent.js index 84b26abe..d9793723 100644 --- a/javascript/mixpanel-persistent.js +++ b/javascript/mixpanel-persistent.js @@ -14,6 +14,38 @@ import { AsyncStorageAdapter } from "./mixpanel-storage"; import uuid from "uuid"; import { MixpanelLogger } from "mixpanel-react-native/javascript/mixpanel-logger"; +/** + * Generate a UUID v4, with cross-platform fallbacks + * Tries: uuid package → Web Crypto API → manual generation + */ +function generateUUID() { + // Try uuid package first (works in React Native with polyfill) + try { + const result = uuid.v4(); + if (result) return result; + } catch (e) { + // Fall through to alternatives + } + + // Try Web Crypto API (modern browsers) + const cryptoObj = + (typeof globalThis !== "undefined" && globalThis.crypto) || + (typeof window !== "undefined" && window.crypto) || + (typeof crypto !== "undefined" && crypto); + + if (cryptoObj && typeof cryptoObj.randomUUID === "function") { + return cryptoObj.randomUUID(); + } + + // Last resort: manual UUID v4 generation using Math.random + // Less secure but functional for device IDs + return "xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx".replace(/[xy]/g, function (c) { + const r = (Math.random() * 16) | 0; + const v = c === "x" ? r : (r & 0x3) | 0x8; + return v.toString(16); + }); +} + export class MixpanelPersistent { static instance; @@ -42,7 +74,7 @@ export class MixpanelPersistent { } async initializationCompletePromise(token) { - Promise.all([ + return Promise.all([ this.loadIdentity(token), this.loadSuperProperties(token), this.loadTimeEvents(token), @@ -67,8 +99,8 @@ export class MixpanelPersistent { this._identity[token].deviceId = storageToken; if (!this._identity[token].deviceId) { - // Generate device ID using uuid.v4() with polyfilled crypto.getRandomValues - this._identity[token].deviceId = uuid.v4(); + // Generate device ID with cross-platform UUID generation + this._identity[token].deviceId = generateUUID(); await this.storageAdapter.setItem( getDeviceIdKey(token), this._identity[token].deviceId diff --git a/package-lock.json b/package-lock.json index 55baafbf..b5bb448f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "3.2.0-beta.2", "license": "Apache-2.0", "dependencies": { - "@react-native-async-storage/async-storage": "^1.21.0", + "@react-native-async-storage/async-storage": "^1.24.0", "react-native-get-random-values": "^1.9.0", "uuid": "3.3.2" }, @@ -24,6 +24,7 @@ "jsdoc": "^4.0.5", "metro-react-native-babel-preset": "^0.63.0", "react-native": "^0.63.3", + "react-native-dotenv": "^3.4.11", "react-test-renderer": "16.13.1" } }, @@ -1201,6 +1202,7 @@ "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.27.1.tgz", "integrity": "sha512-1x3D2xEk2fRo3PAhwQwu5UubzgiVWSXTBfWpVd2Mx2AzRqJuDJCsgaDVZ7HB5iGzDW1Hl1sWN2mFyKjmR9uAog==", "license": "MIT", + "peer": true, "engines": { "node": ">=6.9.0" } @@ -2057,9 +2059,10 @@ } }, "node_modules/@react-native-async-storage/async-storage": { - "version": "1.23.1", - "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-1.23.1.tgz", - "integrity": "sha512-Qd2kQ3yi6Y3+AcUlrHxSLlnBvpdCEMVGFlVBneVOjaFaPU61g1huc38g339ysXspwY1QZA2aNhrk/KlHGO+ewA==", + "version": "1.24.0", + "resolved": "https://registry.npmjs.org/@react-native-async-storage/async-storage/-/async-storage-1.24.0.tgz", + "integrity": "sha512-W4/vbwUOYOjco0x3toB8QCr7EjIP6nE9G7o8PMguvvjYT5Awg09lyV4enACRx4s++PPulBiBSjL0KTFx2u0Z/g==", + "license": "MIT", "dependencies": { "merge-options": "^3.0.4" }, @@ -5086,6 +5089,19 @@ "node": ">=8" } }, + "node_modules/dotenv": { + "version": "16.6.1", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.6.1.tgz", + "integrity": "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, "node_modules/ee-first": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/ee-first/-/ee-first-1.1.1.tgz", @@ -12864,6 +12880,19 @@ "react": "16.13.1" } }, + "node_modules/react-native-dotenv": { + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/react-native-dotenv/-/react-native-dotenv-3.4.11.tgz", + "integrity": "sha512-6vnIE+WHABSeHCaYP6l3O1BOEhWxKH6nHAdV7n/wKn/sciZ64zPPp2NUdEUf1m7g4uuzlLbjgr+6uDt89q2DOg==", + "dev": true, + "license": "MIT", + "dependencies": { + "dotenv": "^16.4.5" + }, + "peerDependencies": { + "@babel/runtime": "^7.20.6" + } + }, "node_modules/react-native-get-random-values": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/react-native-get-random-values/-/react-native-get-random-values-1.11.0.tgz", diff --git a/package.json b/package.json index 056ae133..84afb0d5 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,7 @@ "jsdoc": "^4.0.5", "metro-react-native-babel-preset": "^0.63.0", "react-native": "^0.63.3", + "react-native-dotenv": "^3.4.11", "react-test-renderer": "16.13.1" }, "jest": { @@ -60,7 +61,7 @@ } }, "dependencies": { - "@react-native-async-storage/async-storage": "^1.21.0", + "@react-native-async-storage/async-storage": "^1.24.0", "react-native-get-random-values": "^1.9.0", "uuid": "3.3.2" } diff --git a/test-js-flags.js b/test-js-flags.js new file mode 100644 index 00000000..f149fc38 --- /dev/null +++ b/test-js-flags.js @@ -0,0 +1,166 @@ +#!/usr/bin/env node + +/** + * Test script to verify JavaScript mode feature flags functionality + * This script simulates an environment where native modules are not available + */ + +// Enable JavaScript mode for testing +process.env.MIXPANEL_ENABLE_JS_FLAGS = 'true'; + +// Mock React Native completely before any imports +const Module = require('module'); +const originalRequire = Module.prototype.require; + +Module.prototype.require = function(id) { + if (id === 'react-native') { + return { + NativeModules: {}, + Platform: { + OS: 'ios', + select: (obj) => obj.ios || obj.default + }, + NativeEventEmitter: class NativeEventEmitter {} + }; + } + // Mock expo-crypto as unavailable + if (id === 'expo-crypto') { + throw new Error('Module not found'); + } + // Let uuid work normally + return originalRequire.apply(this, arguments); +}; + +// Mock React Native modules +global.NativeModules = {}; + +// Mock AsyncStorage +const storage = new Map(); +global.AsyncStorage = { + getItem: async (key) => storage.get(key) || null, + setItem: async (key, value) => { + storage.set(key, value); + return Promise.resolve(); + }, + removeItem: async (key) => { + storage.delete(key); + return Promise.resolve(); + }, + getAllKeys: async () => Array.from(storage.keys()), + multiGet: async (keys) => keys.map(key => [key, storage.get(key) || null]), + multiSet: async (keyValuePairs) => { + keyValuePairs.forEach(([key, value]) => storage.set(key, value)); + return Promise.resolve(); + }, + multiRemove: async (keys) => { + keys.forEach(key => storage.delete(key)); + return Promise.resolve(); + } +}; + +// Import Mixpanel +const { Mixpanel } = require('./index.js'); + +async function testJavaScriptModeFlags() { + console.log('🧪 Testing JavaScript Mode Feature Flags\n'); + console.log('===================================\n'); + + try { + // Create Mixpanel instance in JavaScript mode + const mixpanel = new Mixpanel('test-token-123', false, false); + console.log('✅ Created Mixpanel instance in JavaScript mode\n'); + + // Verify we're in JavaScript mode + const isNativeMode = mixpanel.mixpanelImpl.constructor.name === 'MixpanelReactNative'; + console.log(`Mode: ${isNativeMode ? 'Native' : 'JavaScript'} ✅\n`); + + // Initialize with feature flags enabled + const success = await mixpanel.init(false, { + featureFlagsOptions: { + enabled: true, + context: { + user_type: 'tester', + environment: 'development' + } + } + }); + console.log(`Initialized: ${success ? '✅' : '❌'}\n`); + + // Access feature flags + const flags = mixpanel.flags; + console.log('✅ Accessed flags property without error\n'); + + // Test synchronous methods + console.log('Testing Synchronous Methods:'); + console.log('----------------------------'); + + const ready = flags.areFlagsReady(); + console.log(`areFlagsReady(): ${ready}`); + + const variant = flags.getVariantSync('test-flag', 'fallback'); + console.log(`getVariantSync('test-flag'): ${variant}`); + + const value = flags.getVariantValueSync('button-color', 'blue'); + console.log(`getVariantValueSync('button-color'): ${value}`); + + const enabled = flags.isEnabledSync('new-feature', false); + console.log(`isEnabledSync('new-feature'): ${enabled}\n`); + + // Test asynchronous methods + console.log('Testing Asynchronous Methods:'); + console.log('-----------------------------'); + + // Load flags (this will fail in test environment but should handle gracefully) + try { + await flags.loadFlags(); + console.log('loadFlags(): Success (unexpected)'); + } catch (error) { + console.log('loadFlags(): Failed gracefully (expected in test) ✅'); + } + + // Test async variants with promises + const asyncVariant = await flags.getVariant('async-test', 'async-fallback'); + console.log(`getVariant('async-test'): ${asyncVariant}`); + + const asyncValue = await flags.getVariantValue('async-color', 'red'); + console.log(`getVariantValue('async-color'): ${asyncValue}`); + + const asyncEnabled = await flags.isEnabled('async-feature', true); + console.log(`isEnabled('async-feature'): ${asyncEnabled}\n`); + + // Test JavaScript-specific features + console.log('Testing JavaScript-Specific Features:'); + console.log('------------------------------------'); + + // Test updateContext (JavaScript mode only) + try { + await flags.updateContext({ + user_type: 'premium', + plan: 'enterprise' + }); + console.log('updateContext(): Success ✅'); + } catch (error) { + console.log(`updateContext(): ${error.message}`); + } + + // Test snake_case aliases + console.log('\nTesting snake_case Aliases:'); + console.log('---------------------------'); + + const snakeReady = flags.are_flags_ready(); + console.log(`are_flags_ready(): ${snakeReady}`); + + const snakeVariant = flags.get_variant_sync('snake-test', 'snake-fallback'); + console.log(`get_variant_sync(): ${snakeVariant}`); + + console.log('\n✅ All JavaScript mode feature flag tests completed successfully!'); + + } catch (error) { + console.error('❌ Test failed:', error.message); + console.error(error.stack); + process.exit(1); + } +} + +// Run the tests +testJavaScriptModeFlags().catch(console.error); \ No newline at end of file