UNPKG

mixpanel-react-native

Version:

Official React Native Tracking Library for Mixpanel Analytics

731 lines (700 loc) 27.9 kB
import { MixpanelFlagsJS } from './mixpanel-flags-js'; import { MixpanelLogger } from './mixpanel-logger'; /** * Core class for using Mixpanel Feature Flags. * * <p>The Flags class provides access to Mixpanel's Feature Flags functionality, enabling * dynamic feature control, A/B testing, and personalized user experiences. Feature flags * allow you to remotely configure your app's features without deploying new code. * * <p>This class is accessed through the {@link Mixpanel#flags} property and is lazy-loaded * to minimize performance impact until feature flags are actually used. * * <p><b>Platform Support:</b> * <ul> * <li><b>Native Mode (iOS/Android):</b> Fully supported with automatic experiment tracking</li> * <li><b>JavaScript Mode (Expo/React Native Web):</b> Planned for future release</li> * </ul> * * <p><b>Key Concepts:</b> * <ul> * <li><b>Feature Name:</b> The unique identifier for your feature flag (e.g., "new-checkout")</li> * <li><b>Variant:</b> An object containing both a key and value representing the feature configuration</li> * <li><b>Variant Key:</b> The identifier for the specific variation (e.g., "control", "treatment")</li> * <li><b>Variant Value:</b> The actual configuration value (can be any JSON-serializable type)</li> * <li><b>Fallback:</b> Default value returned when a flag is not available or not loaded</li> * </ul> * * <p><b>Automatic Experiment Tracking:</b> When a feature flag is evaluated for the first time, * Mixpanel automatically tracks a "$experiment_started" event with relevant metadata. * * For EU data residency use https://api-eu.mixpanel.com; for India use https://api-in.mixpanel.com. * * @example * // Initialize with feature flags enabled * const mixpanel = new Mixpanel('YOUR_TOKEN', true); * await mixpanel.init(false, {}, 'https://api.mixpanel.com', false, { * enabled: true, * context: { custom_properties: { platform: 'mobile' } } * }); * * @example * // Synchronous access (when flags are ready) * if (mixpanel.flags.areFlagsReady()) { * const isEnabled = mixpanel.flags.isEnabledSync('new-feature', false); * const color = mixpanel.flags.getVariantValueSync('button-color', 'blue'); * const variant = mixpanel.flags.getVariantSync('checkout-flow', { * key: 'control', * value: 'standard' * }); * } * * @example * // Asynchronous access with Promise pattern * const variant = await mixpanel.flags.getVariant('pricing-test', { * key: 'control', * value: { price: 9.99, currency: 'USD' } * }); * * @example * // Asynchronous access with callback pattern * mixpanel.flags.isEnabled('beta-features', false, (isEnabled) => { * if (isEnabled) { * // Enable beta features * } * }); * * @see Mixpanel#flags */ export class Flags { constructor(token, mixpanelImpl) { this.token = token; this.mixpanelImpl = mixpanelImpl; this.isNativeMode = typeof mixpanelImpl.loadFlags === 'function'; if (!this.isNativeMode) { // Reuse the adapter MixpanelPersistent already built so flags inherit // the same "user-supplied storage → auto-required AsyncStorage → // InMemoryStorage fallback" resolution the rest of persistence uses. this.jsFlags = new MixpanelFlagsJS( token, mixpanelImpl, mixpanelImpl.mixpanelPersistent.storageAdapter, mixpanelImpl.getFeatureFlagsOptions() ); this.jsFlags.init(); } } /** * Manually fetch feature flags from the Mixpanel servers. * * <p>Feature flags are automatically loaded during initialization when feature flags are enabled. * This method allows you to manually trigger a refresh of the flags, which is useful when: * <ul> * <li>You want to reload flags after a user property change</li> * <li>You need to ensure you have the latest flag configuration</li> * <li>Initial automatic load failed and you want to retry</li> * </ul> * * <p>After successfully loading flags, {@link areFlagsReady} will return true and synchronous * methods can be used to access flag values. * * @returns {Promise<void>} A promise that resolves when flags have been fetched and loaded * * @example * // Manually reload flags after user identification * await mixpanel.identify('user123'); * await mixpanel.flags.loadFlags(); */ async loadFlags() { if (this.isNativeMode) { return await this.mixpanelImpl.loadFlags(this.token); } else if (this.jsFlags) { return await this.jsFlags.loadFlags(); } // Log warning and return gracefully instead of throwing MixpanelLogger.warn(this.token, "Feature flags are not initialized - cannot load flags"); return; } /** * Check if feature flags have been fetched from the server and are ready to use. * * <p>This method returns true after feature flags have been successfully loaded via {@link loadFlags} * or during initialization. When flags are ready, you can safely use the synchronous methods * ({@link getVariantSync}, {@link getVariantValueSync}, {@link isEnabledSync}) without waiting. * * <p>It's recommended to check this before using synchronous methods to ensure you're not * getting fallback values due to flags not being loaded yet. * * @returns {boolean} true if flags have been loaded and are ready to use, false otherwise * * @example * // Check before using synchronous methods * if (mixpanel.flags.areFlagsReady()) { * const isEnabled = mixpanel.flags.isEnabledSync('new-feature', false); * } else { * console.log('Flags not ready yet, using fallback'); * } * * @example * // Wait for flags to be ready * await mixpanel.flags.loadFlags(); * if (mixpanel.flags.areFlagsReady()) { * // Now safe to use sync methods * } */ areFlagsReady() { if (this.isNativeMode) { return this.mixpanelImpl.areFlagsReadySync(this.token); } else if (this.jsFlags) { return this.jsFlags.areFlagsReady(); } return false; } /** * Get a feature flag variant synchronously. * * <p>Returns the complete variant object for a feature flag, including both the variant key * (e.g., "control", "treatment") and the variant value (the actual configuration data). * * <p><b>Important:</b> This is a synchronous method that only works when flags are ready. * Always check {@link areFlagsReady} first, or use the asynchronous {@link getVariant} method instead. * * <p>When a flag is evaluated for the first time, Mixpanel automatically tracks a * "$experiment_started" event with relevant experiment metadata. * * @param {string} featureName The unique identifier for the feature flag * @param {object} fallback The fallback variant object to return if the flag is not available. * Must include both 'key' and 'value' properties. * @returns {object} The flag variant object with the following structure: * - key: {string} The variant key (e.g., "control", "treatment") * - value: {any} The variant value (can be any JSON-serializable type) * - experiment_id: {string|number} (optional) The experiment ID if this is an experiment * - is_experiment_active: {boolean} (optional) Whether the experiment is currently active * * @example * // Get a checkout flow variant * if (mixpanel.flags.areFlagsReady()) { * const variant = mixpanel.flags.getVariantSync('checkout-flow', { * key: 'control', * value: 'standard' * }); * console.log(`Using variant: ${variant.key}`); * console.log(`Configuration: ${JSON.stringify(variant.value)}`); * } * * @example * // Get a complex configuration variant * const defaultConfig = { * key: 'default', * value: { * theme: 'light', * layout: 'grid', * itemsPerPage: 20 * } * }; * const config = mixpanel.flags.getVariantSync('ui-config', defaultConfig); * * @see getVariant for asynchronous access * @see getVariantValueSync to get only the value (not the full variant object) */ getVariantSync(featureName, fallback) { if (this.isNativeMode) { return this.mixpanelImpl.getVariantSync(this.token, featureName, fallback); } if (this.jsFlags) { return this.jsFlags.getVariantSync(featureName, fallback); } return fallback; } /** * Get a feature flag variant value synchronously. * * <p>Returns only the value portion of a feature flag variant, without the variant key or metadata. * This is useful when you only care about the configuration data, not which variant was selected. * * <p><b>Important:</b> This is a synchronous method that only works when flags are ready. * Always check {@link areFlagsReady} first, or use the asynchronous {@link getVariantValue} method instead. * * <p>When a flag is evaluated for the first time, Mixpanel automatically tracks a * "$experiment_started" event with relevant experiment metadata. * * @param {string} featureName The unique identifier for the feature flag * @param {any} fallbackValue The fallback value to return if the flag is not available. * Can be any JSON-serializable type (string, number, boolean, object, array, etc.) * @returns {any} The flag's value, or the fallback if the flag is not available. * The return type matches the type of value configured in your Mixpanel project. * * @example * // Get a simple string value * if (mixpanel.flags.areFlagsReady()) { * const buttonColor = mixpanel.flags.getVariantValueSync('button-color', 'blue'); * applyButtonColor(buttonColor); * } * * @example * // Get a complex object value * const defaultPricing = { price: 9.99, currency: 'USD', trial_days: 7 }; * const pricing = mixpanel.flags.getVariantValueSync('pricing-config', defaultPricing); * console.log(`Price: ${pricing.price} ${pricing.currency}`); * * @example * // Get a boolean value * const showPromo = mixpanel.flags.getVariantValueSync('show-promo', false); * if (showPromo) { * displayPromotionalBanner(); * } * * @see getVariantValue for asynchronous access * @see getVariantSync to get the full variant object including key and metadata */ getVariantValueSync(featureName, fallbackValue) { if (this.isNativeMode) { // Android returns a wrapped object due to React Native limitations const result = this.mixpanelImpl.getVariantValueSync(this.token, featureName, fallbackValue); if (result && typeof result === 'object' && 'type' in result) { // Android wraps the response return result.type === 'fallback' ? fallbackValue : result.value; } // iOS returns the value directly return result; } else if (this.jsFlags) { return this.jsFlags.getVariantValueSync(featureName, fallbackValue); } return fallbackValue; } /** * Check if a feature flag is enabled synchronously. * * <p>This is a convenience method for boolean feature flags. It checks if a feature is enabled * by evaluating the variant value as a boolean. A feature is considered "enabled" when its * variant value evaluates to true. * * <p><b>Important:</b> This is a synchronous method that only works when flags are ready. * Always check {@link areFlagsReady} first, or use the asynchronous {@link isEnabled} method instead. * * <p>When a flag is evaluated for the first time, Mixpanel automatically tracks a * "$experiment_started" event with relevant experiment metadata. * * @param {string} featureName The unique identifier for the feature flag * @param {boolean} [fallbackValue=false] The fallback value to return if the flag is not available. * Defaults to false if not provided. * @returns {boolean} true if the feature is enabled, false otherwise * * @example * // Simple feature toggle * if (mixpanel.flags.areFlagsReady()) { * if (mixpanel.flags.isEnabledSync('new-checkout', false)) { * showNewCheckout(); * } else { * showLegacyCheckout(); * } * } * * @example * // With explicit fallback * const enableBetaFeatures = mixpanel.flags.isEnabledSync('beta-features', true); * * @see isEnabled for asynchronous access * @see getVariantValueSync for non-boolean flag values */ isEnabledSync(featureName, fallbackValue = false) { if (this.isNativeMode) { return this.mixpanelImpl.isEnabledSync(this.token, featureName, fallbackValue); } else if (this.jsFlags) { return this.jsFlags.isEnabledSync(featureName, fallbackValue); } return fallbackValue; } /** * Get a feature flag variant asynchronously. * * <p>Returns the complete variant object for a feature flag, including both the variant key * and the variant value. This method works regardless of whether flags are ready, making it * safe to use at any time. * * <p>Supports both Promise and callback patterns for maximum flexibility. * * <p>When a flag is evaluated for the first time, Mixpanel automatically tracks a * "$experiment_started" event with relevant experiment metadata. * * @param {string} featureName The unique identifier for the feature flag * @param {object} fallback The fallback variant object to return if the flag is not available. * Must include both 'key' and 'value' properties. * @param {function} [callback] Optional callback function that receives the variant object. * If provided, the method returns void. If omitted, the method returns a Promise. * @returns {Promise<object>|void} Promise that resolves to the variant object if no callback provided, * void if callback is provided. The variant object has the following structure: * - key: {string} The variant key (e.g., "control", "treatment") * - value: {any} The variant value (can be any JSON-serializable type) * - experiment_id: {string|number} (optional) The experiment ID if this is an experiment * - is_experiment_active: {boolean} (optional) Whether the experiment is currently active * * @example * // Promise pattern (recommended) * const variant = await mixpanel.flags.getVariant('checkout-flow', { * key: 'control', * value: 'standard' * }); * console.log(`Using ${variant.key}: ${variant.value}`); * * @example * // Callback pattern * mixpanel.flags.getVariant('pricing-test', { * key: 'default', * value: { price: 9.99 } * }, (variant) => { * console.log(`Price: ${variant.value.price}`); * }); * * @see getVariantSync for synchronous access when flags are ready * @see getVariantValue to get only the value without the variant key */ getVariant(featureName, fallback, callback) { // If callback provided, use callback pattern if (typeof callback === 'function') { if (this.isNativeMode) { this.mixpanelImpl.getVariant(this.token, featureName, fallback) .then(result => callback(result)) .catch((error) => { MixpanelLogger.error(this.token, `Failed to get variant for ${featureName}:`, error); callback(fallback); }); } else if (this.jsFlags) { this.jsFlags.getVariant(featureName, fallback) .then(result => callback(result)) .catch((error) => { MixpanelLogger.error(this.token, `Failed to get variant for ${featureName}:`, error); callback(fallback); }); } else { callback(fallback); } return; } // Promise pattern return new Promise((resolve) => { if (this.isNativeMode) { this.mixpanelImpl.getVariant(this.token, featureName, fallback) .then(resolve) .catch((error) => { MixpanelLogger.error(this.token, `Failed to get variant for ${featureName}:`, error); resolve(fallback); }); } else if (this.jsFlags) { this.jsFlags.getVariant(featureName, fallback) .then(resolve) .catch((error) => { MixpanelLogger.error(this.token, `Failed to get variant for ${featureName}:`, error); resolve(fallback); }); } else { resolve(fallback); } }); } /** * Get a feature flag variant value asynchronously. * * <p>Returns only the value portion of a feature flag variant. This method works regardless * of whether flags are ready, making it safe to use at any time. * * <p>Supports both Promise and callback patterns for maximum flexibility. * * <p>When a flag is evaluated for the first time, Mixpanel automatically tracks a * "$experiment_started" event with relevant experiment metadata. * * @param {string} featureName The unique identifier for the feature flag * @param {any} fallbackValue The fallback value to return if the flag is not available. * Can be any JSON-serializable type (string, number, boolean, object, array, etc.) * @param {function} [callback] Optional callback function that receives the flag value. * If provided, the method returns void. If omitted, the method returns a Promise. * @returns {Promise<any>|void} Promise that resolves to the flag value if no callback provided, * void if callback is provided. The return type matches the type of value configured in * your Mixpanel project. * * @example * // Promise pattern (recommended) * const buttonColor = await mixpanel.flags.getVariantValue('button-color', 'blue'); * applyButtonColor(buttonColor); * * @example * // Promise pattern with object value * const pricing = await mixpanel.flags.getVariantValue('pricing-config', { * price: 9.99, * currency: 'USD' * }); * displayPrice(pricing.price, pricing.currency); * * @example * // Callback pattern * mixpanel.flags.getVariantValue('theme', 'light', (theme) => { * applyTheme(theme); * }); * * @see getVariantValueSync for synchronous access when flags are ready * @see getVariant to get the full variant object including key and metadata */ getVariantValue(featureName, fallbackValue, callback) { // If callback provided, use callback pattern if (typeof callback === 'function') { if (this.isNativeMode) { this.mixpanelImpl.getVariantValue(this.token, featureName, fallbackValue) .then(result => callback(result)) .catch((error) => { MixpanelLogger.error(this.token, `Failed to get variant value for ${featureName}:`, error); callback(fallbackValue); }); } else if (this.jsFlags) { this.jsFlags.getVariantValue(featureName, fallbackValue) .then(result => callback(result)) .catch((error) => { MixpanelLogger.error(this.token, `Failed to get variant value for ${featureName}:`, error); callback(fallbackValue); }); } else { callback(fallbackValue); } return; } // Promise pattern return new Promise((resolve) => { if (this.isNativeMode) { this.mixpanelImpl.getVariantValue(this.token, featureName, fallbackValue) .then(resolve) .catch((error) => { MixpanelLogger.error(this.token, `Failed to get variant value for ${featureName}:`, error); resolve(fallbackValue); }); } else if (this.jsFlags) { this.jsFlags.getVariantValue(featureName, fallbackValue) .then(resolve) .catch((error) => { MixpanelLogger.error(this.token, `Failed to get variant value for ${featureName}:`, error); resolve(fallbackValue); }); } else { resolve(fallbackValue); } }); } /** * Check if a feature flag is enabled asynchronously. * * <p>This is a convenience method for boolean feature flags. It checks if a feature is enabled * by evaluating the variant value as a boolean. This method works regardless of whether flags * are ready, making it safe to use at any time. * * <p>Supports both Promise and callback patterns for maximum flexibility. * * <p>When a flag is evaluated for the first time, Mixpanel automatically tracks a * "$experiment_started" event with relevant experiment metadata. * * @param {string} featureName The unique identifier for the feature flag * @param {boolean} [fallbackValue=false] The fallback value to return if the flag is not available. * Defaults to false if not provided. * @param {function} [callback] Optional callback function that receives the boolean result. * If provided, the method returns void. If omitted, the method returns a Promise. * @returns {Promise<boolean>|void} Promise that resolves to true if enabled, false otherwise * (when no callback provided). Returns void if callback is provided. * * @example * // Promise pattern (recommended) * const isEnabled = await mixpanel.flags.isEnabled('new-checkout', false); * if (isEnabled) { * showNewCheckout(); * } else { * showLegacyCheckout(); * } * * @example * // Callback pattern * mixpanel.flags.isEnabled('beta-features', false, (isEnabled) => { * if (isEnabled) { * enableBetaFeatures(); * } * }); * * @example * // Default fallback (false) * const showPromo = await mixpanel.flags.isEnabled('show-promo'); * * @see isEnabledSync for synchronous access when flags are ready * @see getVariantValue for non-boolean flag values */ isEnabled(featureName, fallbackValue = false, callback) { // If callback provided, use callback pattern if (typeof callback === 'function') { if (this.isNativeMode) { this.mixpanelImpl.isEnabled(this.token, featureName, fallbackValue) .then(result => callback(result)) .catch((error) => { MixpanelLogger.error(this.token, `Failed to check if ${featureName} is enabled:`, error); callback(fallbackValue); }); } else if (this.jsFlags) { this.jsFlags.isEnabled(featureName, fallbackValue) .then(result => callback(result)) .catch((error) => { MixpanelLogger.error(this.token, `Failed to check if ${featureName} is enabled:`, error); callback(fallbackValue); }); } else { callback(fallbackValue); } return; } // Promise pattern return new Promise((resolve) => { if (this.isNativeMode) { this.mixpanelImpl.isEnabled(this.token, featureName, fallbackValue) .then(resolve) .catch((error) => { MixpanelLogger.error(this.token, `Failed to check if ${featureName} is enabled:`, error); resolve(fallbackValue); }); } else if (this.jsFlags) { this.jsFlags.isEnabled(featureName, fallbackValue) .then(resolve) .catch((error) => { MixpanelLogger.error(this.token, `Failed to check if ${featureName} is enabled:`, error); resolve(fallbackValue); }); } else { resolve(fallbackValue); } }); } /** Get all loaded variants as a Map keyed by feature name. Resolves to an empty Map on failure. */ getAllVariants(callback) { const run = (resolve) => { const handleError = (error) => { MixpanelLogger.error(this.token, "Failed to get all variants:", error); resolve(new Map()); }; if (this.isNativeMode) { this.mixpanelImpl .getAllVariants(this.token) .then((obj) => resolve(new Map(Object.entries(obj || {})))) .catch(handleError); } else if (this.jsFlags) { this.jsFlags.getAllVariants().then(resolve).catch(handleError); } else { resolve(new Map()); } }; if (typeof callback === 'function') { run((variants) => callback(variants)); return; } return new Promise(run); } /** Synchronous variant of {@link getAllVariants}; returns whatever is in memory. */ getAllVariantsSync() { if (this.isNativeMode) { return new Map( Object.entries(this.mixpanelImpl.getAllVariantsSync(this.token) || {}) ); } else if (this.jsFlags) { return this.jsFlags.getAllVariantsSync(); } return new Map(); } /** * Update the context used for feature flag evaluation. * * In JavaScript mode, `options.replace` controls merge vs. replace semantics * (merge by default). In native mode, the underlying iOS/Android SDKs always * merge context and do not currently expose a replace toggle, so `options` is * accepted for forward compatibility but ignored — not forwarded across the * bridge. Requires Mixpanel-swift 6.4+ / mixpanel-android 8.8+ on native. */ async updateContext(newContext, options = { replace: false }) { if (this.isNativeMode) { return await this.mixpanelImpl.updateFlagsContext( this.token, newContext || {} ); } else if (this.jsFlags) { return await this.jsFlags.updateContext(newContext, options); } throw new Error("Feature flags are not initialized"); } /** * Notify the flags system of a tracked event so any matching first-time-event * activations are recorded. No-op on native (handled by the iOS/Android SDKs). */ checkFirstTimeEvents(eventName, properties) { if (this.isNativeMode) { return; } if (this.jsFlags) { this.jsFlags.checkFirstTimeEvents(eventName, properties); } } // snake_case aliases /** Alias for {@link areFlagsReady}. */ are_flags_ready() { return this.areFlagsReady(); } /** Alias for {@link getVariant}. */ get_variant(featureName, fallback, callback) { return this.getVariant(featureName, fallback, callback); } /** Alias for {@link getVariantSync}. */ get_variant_sync(featureName, fallback) { return this.getVariantSync(featureName, fallback); } /** Alias for {@link getVariantValue}. */ get_variant_value(featureName, fallbackValue, callback) { return this.getVariantValue(featureName, fallbackValue, callback); } /** Alias for {@link getVariantValueSync}. */ get_variant_value_sync(featureName, fallbackValue) { return this.getVariantValueSync(featureName, fallbackValue); } /** Alias for {@link isEnabled}. */ is_enabled(featureName, fallbackValue, callback) { return this.isEnabled(featureName, fallbackValue, callback); } /** Alias for {@link isEnabledSync}. */ is_enabled_sync(featureName, fallbackValue) { return this.isEnabledSync(featureName, fallbackValue); } /** * Clear feature flag state. Native mode is a no-op — the native SDKs handle * flag re-fetch on their own reset. JS-fallback mode clears the in-memory * map, removes the persisted blob, and triggers a fresh fetch under the * new identity. */ async reset() { if (this.isNativeMode) { return; } if (this.jsFlags) { return this.jsFlags.reset(); } } /** Alias for {@link getAllVariants}. */ get_all_variants(callback) { return this.getAllVariants(callback); } /** Alias for {@link getAllVariantsSync}. */ get_all_variants_sync() { return this.getAllVariantsSync(); } /** Alias for {@link loadFlags}. */ load_flags() { return this.loadFlags(); } /** * Alias for {@link updateContext}. * @see updateContext */ update_context(newContext, options) { return this.updateContext(newContext, options); } /** Alias for {@link checkFirstTimeEvents}. */ check_first_time_events(eventName, properties) { return this.checkFirstTimeEvents(eventName, properties); } }