From 8cc8583562b03bd1e47c1aaf94ff2e7815818618 Mon Sep 17 00:00:00 2001 From: Rahul Raveendran V P Date: Thu, 30 Jul 2026 14:52:35 +0530 Subject: [PATCH 01/10] feat(autocapture): add autocapture options bridge to native SDKs Update the React Native bridge to accept autocaptureOptions during initialization and pass them to both iOS and Android native SDKs. Enables walkUpToClickableParent(true) so native autocapture correctly resolves $el_id from accessibilityLabel on React Native view wrappers instead of leaf text views. Co-Authored-By: Claude Sonnet 4.6 --- MixpanelReactNative.podspec | 3 +- android/build.gradle | 4 +- .../MixpanelReactNativeModule.java | 64 ++++++++++++++++- index.d.ts | 26 ++++++- index.js | 69 ++++++++++++++++++- ios/MixpanelReactNative.m | 2 +- ios/MixpanelReactNative.swift | 44 +++++++++++- 7 files changed, 203 insertions(+), 9 deletions(-) diff --git a/MixpanelReactNative.podspec b/MixpanelReactNative.podspec index c47703af..e1943710 100644 --- a/MixpanelReactNative.podspec +++ b/MixpanelReactNative.podspec @@ -19,5 +19,6 @@ Pod::Spec.new do |s| s.pod_target_xcconfig = { 'DEFINES_MODULE' => 'YES' } s.dependency "React-Core" - s.dependency "Mixpanel-swift", '6.5.0' + # Local mixpanel-swift with autocapture support + s.dependency "Mixpanel-swift", '~> 6.5' end diff --git a/android/build.gradle b/android/build.gradle index 802f4dfe..ddac641d 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -32,6 +32,7 @@ android { } repositories { + mavenLocal() // Local builds of mixpanel-android with autocapture support mavenCentral() maven { url 'https://maven.google.com/' @@ -41,5 +42,6 @@ repositories { dependencies { implementation 'com.facebook.react:react-native:+' - implementation 'com.mixpanel.android:mixpanel-android:8.9.0' + // Local mixpanel-android with autocapture support (publish locally with: ./gradlew :analytics:publishToMavenLocal -x signReleasePublication) + implementation 'com.mixpanel.android:mixpanel-android:9.0.0-beta' } diff --git a/android/src/main/java/com/mixpanel/reactnative/MixpanelReactNativeModule.java b/android/src/main/java/com/mixpanel/reactnative/MixpanelReactNativeModule.java index b6927834..b52487bc 100644 --- a/android/src/main/java/com/mixpanel/reactnative/MixpanelReactNativeModule.java +++ b/android/src/main/java/com/mixpanel/reactnative/MixpanelReactNativeModule.java @@ -1,10 +1,14 @@ package com.mixpanel.reactnative; +import com.mixpanel.android.mpmetrics.AutocaptureOptions; +import com.mixpanel.android.mpmetrics.ClickOptions; +import com.mixpanel.android.mpmetrics.DeadClickOptions; import com.mixpanel.android.mpmetrics.FeatureFlagOptions; import com.mixpanel.android.mpmetrics.MixpanelAPI; import com.mixpanel.android.mpmetrics.MixpanelOptions; import com.mixpanel.android.mpmetrics.MixpanelFlagVariant; import com.mixpanel.android.mpmetrics.FlagCompletionCallback; +import com.mixpanel.android.mpmetrics.RageClickOptions; import com.mixpanel.android.mpmetrics.VariantLookupPolicy; import com.facebook.react.bridge.Promise; @@ -44,7 +48,7 @@ public String getName() { @ReactMethod - public void initialize(String token, boolean trackAutomaticEvents, boolean optOutTrackingDefault, ReadableMap metadata, String serverURL, boolean useGzipCompression, ReadableMap featureFlagsOptions, Promise promise) throws JSONException { + public void initialize(String token, boolean trackAutomaticEvents, boolean optOutTrackingDefault, ReadableMap metadata, String serverURL, boolean useGzipCompression, ReadableMap featureFlagsOptions, ReadableMap autocaptureConfig, Promise promise) throws JSONException { JSONObject mixpanelProperties = ReactNativeHelper.reactToJSON(metadata); AutomaticProperties.setAutomaticProperties(mixpanelProperties); @@ -81,6 +85,12 @@ public void initialize(String token, boolean trackAutomaticEvents, boolean optOu optionsBuilder.featureFlagOptions(ffBuilder.build()); } + // Configure autocapture if provided + if (autocaptureConfig != null) { + AutocaptureOptions autocaptureOptions = buildAutocaptureOptions(autocaptureConfig); + optionsBuilder.autocaptureOptions(autocaptureOptions); + } + MixpanelAPI instance = MixpanelAPI.getInstance(this.mReactContext, token, trackAutomaticEvents, optionsBuilder.build()); if (useGzipCompression) { instance.setShouldGzipRequestPayload(true); @@ -88,6 +98,58 @@ public void initialize(String token, boolean trackAutomaticEvents, boolean optOu promise.resolve(null); } + private AutocaptureOptions buildAutocaptureOptions(ReadableMap config) { + AutocaptureOptions.Builder builder = new AutocaptureOptions.Builder(); + + if (config.hasKey("click")) { + ReadableMap clickConfig = config.getMap("click"); + if (clickConfig != null) { + ClickOptions.Builder clickBuilder = new ClickOptions.Builder(); + if (clickConfig.hasKey("enabled")) { + clickBuilder.enabled(clickConfig.getBoolean("enabled")); + } + clickBuilder.walkUpToClickableParent(true); + builder.clickOptions(clickBuilder.build()); + } + } + + if (config.hasKey("rageClick")) { + ReadableMap rageConfig = config.getMap("rageClick"); + if (rageConfig != null) { + RageClickOptions.Builder rageBuilder = new RageClickOptions.Builder(); + if (rageConfig.hasKey("enabled")) { + rageBuilder.enabled(rageConfig.getBoolean("enabled")); + } + if (rageConfig.hasKey("clickThreshold")) { + rageBuilder.clickThreshold(rageConfig.getInt("clickThreshold")); + } + if (rageConfig.hasKey("timeWindowMs")) { + rageBuilder.timeWindowMs((long) rageConfig.getDouble("timeWindowMs")); + } + if (rageConfig.hasKey("radius")) { + rageBuilder.radius((float) rageConfig.getDouble("radius")); + } + builder.rageClickOptions(rageBuilder.build()); + } + } + + if (config.hasKey("deadClick")) { + ReadableMap deadConfig = config.getMap("deadClick"); + if (deadConfig != null) { + DeadClickOptions.Builder deadBuilder = new DeadClickOptions.Builder(); + if (deadConfig.hasKey("enabled")) { + deadBuilder.enabled(deadConfig.getBoolean("enabled")); + } + if (deadConfig.hasKey("timeWindowMs")) { + deadBuilder.timeWindowMs((long) deadConfig.getDouble("timeWindowMs")); + } + builder.deadClickOptions(deadBuilder.build()); + } + } + + return builder.build(); + } + private VariantLookupPolicy parseVariantLookupPolicy(ReadableMap policyMap) { if (policyMap == null || !policyMap.hasKey("variantLookupPolicy")) { return null; diff --git a/index.d.ts b/index.d.ts index 758d6a08..d121bf21 100644 --- a/index.d.ts +++ b/index.d.ts @@ -102,6 +102,29 @@ export interface Flags { check_first_time_events(eventName: string, properties?: MixpanelProperties): void; } +export interface AutocaptureClickOptions { + enabled?: boolean; +} + +export interface AutocaptureRageClickOptions { + enabled?: boolean; + clickThreshold?: number; + timeWindowMs?: number; + /** Spatial threshold. Unit: dp on Android, pt on iOS. */ + radius?: number; +} + +export interface AutocaptureDeadClickOptions { + enabled?: boolean; + timeWindowMs?: number; +} + +export interface AutocaptureOptions { + click?: boolean | AutocaptureClickOptions; + rageClick?: boolean | AutocaptureRageClickOptions; + deadClick?: boolean | AutocaptureDeadClickOptions; +} + export class Autocapture { trackScreenView(screenName: string, properties?: MixpanelProperties): void; trackScreenLeave(screenName: string, properties?: MixpanelProperties): void; @@ -129,7 +152,8 @@ export class Mixpanel { superProperties?: MixpanelProperties, serverURL?: string, useGzipCompression?: boolean, - featureFlagsOptions?: FeatureFlagsOptions + featureFlagsOptions?: FeatureFlagsOptions, + autocaptureOptions?: AutocaptureOptions | null ): Promise; setServerURL(serverURL: string): void; setLoggingEnabled(loggingEnabled: boolean): void; diff --git a/index.js b/index.js index 2c0106c7..2f99d098 100644 --- a/index.js +++ b/index.js @@ -141,6 +141,13 @@ export class Mixpanel { * Use the `custom_properties` key to nest targeting properties * (e.g., `context: { custom_properties: { user_tier: 'premium' } }`). * Note: In native mode, context must be set during initialization and cannot be updated later. + * @param {object} [autocaptureOptions=null] Autocapture configuration. Pass an object to enable autocapture. + * Requires native mode (useNative: true). Pass null or omit to disable autocapture. + * @param {boolean|object} [autocaptureOptions.click=true] Enable click tracking. Pass boolean or {enabled: boolean}. + * @param {boolean|object} [autocaptureOptions.rageClick=true] Enable rage click detection. + * Object form: {enabled, clickThreshold, timeWindowMs, radius} + * @param {boolean|object} [autocaptureOptions.deadClick=true] Enable dead click detection. + * Object form: {enabled, timeWindowMs} * @returns {Promise} A promise that resolves when initialization is complete * * @example @@ -175,11 +182,24 @@ export class Mixpanel { superProperties = {}, serverURL = "https://api.mixpanel.com", useGzipCompression = false, - featureFlagsOptions = {} + featureFlagsOptions = {}, + autocaptureOptions = null ) { // Store feature flags options for later use this.featureFlagsOptions = featureFlagsOptions; + // Normalize autocapture options + let resolvedAutocaptureOptions = null; + if (autocaptureOptions != null) { + if (this.mixpanelImpl !== MixpanelReactNative) { + console.warn( + "Mixpanel autocapture requires native mode (useNative: true). Autocapture config will be ignored in JavaScript mode." + ); + } else { + resolvedAutocaptureOptions = AutocaptureHelper.normalizeOptions(autocaptureOptions); + } + } + await this.mixpanelImpl.initialize( this.token, this.trackAutomaticEvents, @@ -187,7 +207,8 @@ export class Mixpanel { {...Helper.getMetaData(), ...superProperties}, serverURL, useGzipCompression, - featureFlagsOptions + featureFlagsOptions, + resolvedAutocaptureOptions ); // If flags are enabled AND we're in native mode, initialize them @@ -224,7 +245,8 @@ export class Mixpanel { Helper.getMetaData(), "https://api.mixpanel.com", false, - {} + {}, + null ); return new Mixpanel(token, trackAutomaticEvents); } @@ -1160,6 +1182,47 @@ class StringHelper { } } +class AutocaptureHelper { + static normalizeOptions(options) { + const normalized = {}; + + // Click options + if (options.click !== undefined) { + if (typeof options.click === "boolean") { + normalized.click = { enabled: options.click }; + } else if (typeof options.click === "object") { + normalized.click = { enabled: true, ...options.click }; + } + } else { + normalized.click = { enabled: true }; + } + + // Rage click options + if (options.rageClick !== undefined) { + if (typeof options.rageClick === "boolean") { + normalized.rageClick = { enabled: options.rageClick }; + } else if (typeof options.rageClick === "object") { + normalized.rageClick = { enabled: true, ...options.rageClick }; + } + } else { + normalized.rageClick = { enabled: true }; + } + + // Dead click options + if (options.deadClick !== undefined) { + if (typeof options.deadClick === "boolean") { + normalized.deadClick = { enabled: options.deadClick }; + } else if (typeof options.deadClick === "object") { + normalized.deadClick = { enabled: true, ...options.deadClick }; + } + } else { + normalized.deadClick = { enabled: true }; + } + + return normalized; + } +} + class ObjectHelper { /** Check whether the parameter is an object. diff --git a/ios/MixpanelReactNative.m b/ios/MixpanelReactNative.m index fa9b6f5c..ea39e6b8 100644 --- a/ios/MixpanelReactNative.m +++ b/ios/MixpanelReactNative.m @@ -5,7 +5,7 @@ @interface RCT_EXTERN_MODULE(MixpanelReactNative, NSObject) // MARK: - Mixpanel Instance -RCT_EXTERN_METHOD(initialize:(NSString *)token trackAutomaticEvents:(BOOL)trackAutomaticEvents optOutTrackingByDefault:(BOOL)optOutTrackingByDefault properties:(NSDictionary *)properties serverURL:(NSString *)serverURL useGzipCompression:(BOOL)useGzipCompression featureFlagsOptions:(NSDictionary *)featureFlagsOptions resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(initialize:(NSString *)token trackAutomaticEvents:(BOOL)trackAutomaticEvents optOutTrackingByDefault:(BOOL)optOutTrackingByDefault properties:(NSDictionary *)properties serverURL:(NSString *)serverURL useGzipCompression:(BOOL)useGzipCompression featureFlagsOptions:(NSDictionary *)featureFlagsOptions autocaptureConfig:(NSDictionary *)autocaptureConfig resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) // Mark: - Settings RCT_EXTERN_METHOD(setServerURL:(NSString *)token serverURL:(NSString *)serverURL resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) diff --git a/ios/MixpanelReactNative.swift b/ios/MixpanelReactNative.swift index ae7a9481..3039d536 100644 --- a/ios/MixpanelReactNative.swift +++ b/ios/MixpanelReactNative.swift @@ -19,6 +19,7 @@ open class MixpanelReactNative: NSObject { serverURL: String, useGzipCompression: Bool = false, featureFlagsOptions: [String: Any]?, + autocaptureConfig: [String: Any]?, resolver resolve: RCTPromiseResolveBlock, rejecter reject: RCTPromiseRejectBlock) -> Void { let autoProps = properties // copy @@ -37,6 +38,11 @@ open class MixpanelReactNative: NSObject { ) } + var resolvedAutocaptureOptions: AutocaptureOptions? = nil + if let config = autocaptureConfig { + resolvedAutocaptureOptions = buildAutocaptureOptions(from: config) + } + let options = MixpanelOptions( token: token, instanceName: token, @@ -45,13 +51,49 @@ open class MixpanelReactNative: NSObject { superProperties: propsProcessed, serverURL: serverURL, useGzipCompression: useGzipCompression, - featureFlagOptions: resolvedFeatureFlagOptions + featureFlagOptions: resolvedFeatureFlagOptions, + autocaptureOptions: resolvedAutocaptureOptions ) Mixpanel.initialize(options: options) resolve(true) } + private func buildAutocaptureOptions(from config: [String: Any]) -> AutocaptureOptions { + var clickOpts = ClickOptions() + var rageClickOpts = RageClickOptions() + var deadClickOpts = DeadClickOptions() + + if let clickConfig = config["click"] as? [String: Any] { + clickOpts = ClickOptions( + enabled: clickConfig["enabled"] as? Bool ?? true, + walkUpToClickableParent: true + ) + } + + if let rageConfig = config["rageClick"] as? [String: Any] { + rageClickOpts = RageClickOptions( + enabled: rageConfig["enabled"] as? Bool ?? true, + clickThreshold: rageConfig["clickThreshold"] as? Int ?? 4, + timeWindowMs: rageConfig["timeWindowMs"] as? Int64 ?? 1000, + radius: rageConfig["radius"] as? CGFloat ?? 44 + ) + } + + if let deadConfig = config["deadClick"] as? [String: Any] { + deadClickOpts = DeadClickOptions( + enabled: deadConfig["enabled"] as? Bool ?? true, + timeWindowMs: deadConfig["timeWindowMs"] as? Int ?? 500 + ) + } + + return AutocaptureOptions( + clickOptions: clickOpts, + rageClickOptions: rageClickOpts, + deadClickOptions: deadClickOpts + ) + } + private func parseVariantLookupPolicy(_ policyMap: [String: Any]?) -> VariantLookupPolicy { guard let policyMap = policyMap, let kind = policyMap["variantLookupPolicy"] as? String else { return .networkOnly From bd3a572ce20bba126dd2130eb807e39189dec4eb Mon Sep 17 00:00:00 2001 From: Rahul Raveendran V P Date: Thu, 30 Jul 2026 15:06:27 +0530 Subject: [PATCH 02/10] refactor(autocapture): pass walkUpToClickableParent at AutocaptureOptions level Update the bridge to set walkUpToClickableParent on AutocaptureOptions instead of ClickOptions, matching the native SDK refactor. Co-Authored-By: Claude Sonnet 4.6 --- .../com/mixpanel/reactnative/MixpanelReactNativeModule.java | 2 +- ios/MixpanelReactNative.swift | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/android/src/main/java/com/mixpanel/reactnative/MixpanelReactNativeModule.java b/android/src/main/java/com/mixpanel/reactnative/MixpanelReactNativeModule.java index b52487bc..324358e3 100644 --- a/android/src/main/java/com/mixpanel/reactnative/MixpanelReactNativeModule.java +++ b/android/src/main/java/com/mixpanel/reactnative/MixpanelReactNativeModule.java @@ -100,6 +100,7 @@ public void initialize(String token, boolean trackAutomaticEvents, boolean optOu private AutocaptureOptions buildAutocaptureOptions(ReadableMap config) { AutocaptureOptions.Builder builder = new AutocaptureOptions.Builder(); + builder.walkUpToClickableParent(true); if (config.hasKey("click")) { ReadableMap clickConfig = config.getMap("click"); @@ -108,7 +109,6 @@ private AutocaptureOptions buildAutocaptureOptions(ReadableMap config) { if (clickConfig.hasKey("enabled")) { clickBuilder.enabled(clickConfig.getBoolean("enabled")); } - clickBuilder.walkUpToClickableParent(true); builder.clickOptions(clickBuilder.build()); } } diff --git a/ios/MixpanelReactNative.swift b/ios/MixpanelReactNative.swift index 3039d536..bafc893c 100644 --- a/ios/MixpanelReactNative.swift +++ b/ios/MixpanelReactNative.swift @@ -66,8 +66,7 @@ open class MixpanelReactNative: NSObject { if let clickConfig = config["click"] as? [String: Any] { clickOpts = ClickOptions( - enabled: clickConfig["enabled"] as? Bool ?? true, - walkUpToClickableParent: true + enabled: clickConfig["enabled"] as? Bool ?? true ) } @@ -90,7 +89,8 @@ open class MixpanelReactNative: NSObject { return AutocaptureOptions( clickOptions: clickOpts, rageClickOptions: rageClickOpts, - deadClickOptions: deadClickOpts + deadClickOptions: deadClickOpts, + walkUpToClickableParent: true ) } From 5a854b60948b051ef3eb8d3c315454c49a3b9314 Mon Sep 17 00:00:00 2001 From: Rahul Raveendran V P Date: Thu, 30 Jul 2026 15:42:59 +0530 Subject: [PATCH 03/10] fix(ios): use native SDK defaults instead of hardcoded values in bridge Read default values from RageClickOptions() and DeadClickOptions() instances instead of hardcoding 4, 1000, 44, 500. This ensures the bridge automatically picks up any default changes in the native SDK. Co-Authored-By: Claude Sonnet 4.6 --- ios/MixpanelReactNative.swift | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/ios/MixpanelReactNative.swift b/ios/MixpanelReactNative.swift index bafc893c..64b447ac 100644 --- a/ios/MixpanelReactNative.swift +++ b/ios/MixpanelReactNative.swift @@ -60,6 +60,10 @@ open class MixpanelReactNative: NSObject { } private func buildAutocaptureOptions(from config: [String: Any]) -> AutocaptureOptions { + // Start with native defaults so we don't hardcode values that may change in the SDK + let defaults = RageClickOptions() + let deadDefaults = DeadClickOptions() + var clickOpts = ClickOptions() var rageClickOpts = RageClickOptions() var deadClickOpts = DeadClickOptions() @@ -73,16 +77,16 @@ open class MixpanelReactNative: NSObject { if let rageConfig = config["rageClick"] as? [String: Any] { rageClickOpts = RageClickOptions( enabled: rageConfig["enabled"] as? Bool ?? true, - clickThreshold: rageConfig["clickThreshold"] as? Int ?? 4, - timeWindowMs: rageConfig["timeWindowMs"] as? Int64 ?? 1000, - radius: rageConfig["radius"] as? CGFloat ?? 44 + clickThreshold: rageConfig["clickThreshold"] as? Int ?? defaults.clickThreshold, + timeWindowMs: rageConfig["timeWindowMs"] as? Int64 ?? defaults.timeWindowMs, + radius: rageConfig["radius"] as? CGFloat ?? defaults.radius ) } if let deadConfig = config["deadClick"] as? [String: Any] { deadClickOpts = DeadClickOptions( enabled: deadConfig["enabled"] as? Bool ?? true, - timeWindowMs: deadConfig["timeWindowMs"] as? Int ?? 500 + timeWindowMs: deadConfig["timeWindowMs"] as? Int ?? deadDefaults.timeWindowMs ) } From 7f0f2831b6c66248f89d3e1a8d34ef5b40e47d2c Mon Sep 17 00:00:00 2001 From: Rahul Raveendran V P Date: Thu, 30 Jul 2026 16:20:35 +0530 Subject: [PATCH 04/10] feat(autocapture): expose trackClick, trackRageClick, trackDeadClick APIs Add public methods for developers to manually track click, rage click, and dead click events with full element metadata (ClickEventData). This enables custom frustration signal detection in cases where the SDK's automatic detection cannot cover. Co-Authored-By: Claude Sonnet 4.6 --- .../MixpanelReactNativeModule.java | 75 +++++++++++++++ index.d.ts | 20 ++++ index.js | 95 +++++++++++++++++++ ios/MixpanelReactNative.m | 6 ++ ios/MixpanelReactNative.swift | 51 ++++++++++ 5 files changed, 247 insertions(+) diff --git a/android/src/main/java/com/mixpanel/reactnative/MixpanelReactNativeModule.java b/android/src/main/java/com/mixpanel/reactnative/MixpanelReactNativeModule.java index 324358e3..ba999ab7 100644 --- a/android/src/main/java/com/mixpanel/reactnative/MixpanelReactNativeModule.java +++ b/android/src/main/java/com/mixpanel/reactnative/MixpanelReactNativeModule.java @@ -1,5 +1,6 @@ package com.mixpanel.reactnative; +import com.mixpanel.android.autocapture.ClickEvent; import com.mixpanel.android.mpmetrics.AutocaptureOptions; import com.mixpanel.android.mpmetrics.ClickOptions; import com.mixpanel.android.mpmetrics.DeadClickOptions; @@ -355,6 +356,80 @@ public void trackScreenLeave(final String token, final String screenName, Readab } } + @ReactMethod + public void trackClick(final String token, ReadableMap clickEventMap, ReadableMap properties, Promise promise) throws JSONException { + MixpanelAPI instance = MixpanelAPI.getInstance(this.mReactContext, token, true); + if (instance == null) { + promise.reject("Instance Error", "Failed to get Mixpanel instance"); + return; + } + synchronized (instance) { + ClickEvent clickEvent = buildClickEvent(clickEventMap); + JSONObject eventProperties = ReactNativeHelper.reactToJSON(properties); + AutomaticProperties.appendLibraryProperties(eventProperties); + if (instance.getAutocapture() != null) { + instance.getAutocapture().trackClick(clickEvent, eventProperties); + } + promise.resolve(null); + } + } + + @ReactMethod + public void trackRageClick(final String token, ReadableMap clickEventMap, ReadableMap properties, Promise promise) throws JSONException { + MixpanelAPI instance = MixpanelAPI.getInstance(this.mReactContext, token, true); + if (instance == null) { + promise.reject("Instance Error", "Failed to get Mixpanel instance"); + return; + } + synchronized (instance) { + ClickEvent clickEvent = buildClickEvent(clickEventMap); + JSONObject eventProperties = ReactNativeHelper.reactToJSON(properties); + AutomaticProperties.appendLibraryProperties(eventProperties); + if (instance.getAutocapture() != null) { + instance.getAutocapture().trackRageClick(clickEvent, eventProperties); + } + promise.resolve(null); + } + } + + @ReactMethod + public void trackDeadClick(final String token, ReadableMap clickEventMap, ReadableMap properties, Promise promise) throws JSONException { + MixpanelAPI instance = MixpanelAPI.getInstance(this.mReactContext, token, true); + if (instance == null) { + promise.reject("Instance Error", "Failed to get Mixpanel instance"); + return; + } + synchronized (instance) { + ClickEvent clickEvent = buildClickEvent(clickEventMap); + JSONObject eventProperties = ReactNativeHelper.reactToJSON(properties); + AutomaticProperties.appendLibraryProperties(eventProperties); + if (instance.getAutocapture() != null) { + instance.getAutocapture().trackDeadClick(clickEvent, eventProperties); + } + promise.resolve(null); + } + } + + private ClickEvent buildClickEvent(ReadableMap map) { + float x = (float) map.getDouble("x"); + float y = (float) map.getDouble("y"); + String elementId = map.getString("elementId"); + ClickEvent.Builder builder = new ClickEvent.Builder(x, y, elementId); + if (map.hasKey("tagName")) { + builder.tagName(map.getString("tagName")); + } + if (map.hasKey("accessibleLabel")) { + builder.accessibleLabel(map.getString("accessibleLabel")); + } + if (map.hasKey("role")) { + builder.role(map.getString("role")); + } + if (map.hasKey("elements")) { + builder.elements(map.getString("elements")); + } + return builder.build(); + } + @ReactMethod public void registerSuperProperties(final String token, ReadableMap properties, Promise promise) throws JSONException { MixpanelAPI instance = MixpanelAPI.getInstance(this.mReactContext, token, true); diff --git a/index.d.ts b/index.d.ts index d121bf21..dd40abb9 100644 --- a/index.d.ts +++ b/index.d.ts @@ -125,9 +125,29 @@ export interface AutocaptureOptions { deadClick?: boolean | AutocaptureDeadClickOptions; } +export interface ClickEventData { + /** Touch X coordinate. */ + x: number; + /** Touch Y coordinate. */ + y: number; + /** Stable identifier for the tapped element. */ + elementId: string; + /** Class name or component type of the tapped element. */ + tagName?: string; + /** Accessibility label of the element. */ + accessibleLabel?: string; + /** Semantic role (e.g., "button", "link", "switch"). */ + role?: string; + /** View hierarchy path, ">" separated. */ + elements?: string; +} + export class Autocapture { trackScreenView(screenName: string, properties?: MixpanelProperties): void; trackScreenLeave(screenName: string, properties?: MixpanelProperties): void; + trackClick(clickEvent: ClickEventData, properties?: MixpanelProperties): void; + trackRageClick(clickEvent: ClickEventData, properties?: MixpanelProperties): void; + trackDeadClick(clickEvent: ClickEventData, properties?: MixpanelProperties): void; } export class Mixpanel { diff --git a/index.js b/index.js index 2f99d098..a044a9ad 100644 --- a/index.js +++ b/index.js @@ -792,6 +792,101 @@ export class Autocapture { }; this.mixpanelImpl.trackScreenLeave(this.token, screenName, mergedProperties); } + + /** + * Track a click event with element metadata. + * + * Use this when your app implements its own click detection and you want to + * track click events with full element metadata in the Mixpanel autocapture format. + * + * @param {object} clickEvent The click event data. + * @param {number} clickEvent.x Touch X coordinate. + * @param {number} clickEvent.y Touch Y coordinate. + * @param {string} clickEvent.elementId Stable identifier for the tapped element. + * @param {string} [clickEvent.tagName] Class name or component type. + * @param {string} [clickEvent.accessibleLabel] Accessibility label. + * @param {string} [clickEvent.role] Semantic role (e.g., "button", "link"). + * @param {string} [clickEvent.elements] View hierarchy path, ">" separated. + * @param {object} [properties] Optional additional properties. + */ + trackClick(clickEvent, properties) { + if (!this._validateClickEvent(clickEvent, "trackClick")) return; + if (!ObjectHelper.isValidOrUndefined(properties)) { + ObjectHelper.raiseError(PARAMS.PROPERTIES); + } + const mergedProperties = { + ...Helper.getMetaData(), + ...properties, + }; + this.mixpanelImpl.trackClick(this.token, clickEvent, mergedProperties); + } + + /** + * Track a rage click event with element metadata. + * + * Use this when your app implements its own rage click detection. + * A rage click typically indicates a user rapidly tapping an unresponsive element. + * + * @param {object} clickEvent The click event data (same shape as trackClick). + * @param {object} [properties] Optional additional properties. + */ + trackRageClick(clickEvent, properties) { + if (!this._validateClickEvent(clickEvent, "trackRageClick")) return; + if (!ObjectHelper.isValidOrUndefined(properties)) { + ObjectHelper.raiseError(PARAMS.PROPERTIES); + } + const mergedProperties = { + ...Helper.getMetaData(), + ...properties, + }; + this.mixpanelImpl.trackRageClick(this.token, clickEvent, mergedProperties); + } + + /** + * Track a dead click event with element metadata. + * + * Use this when your app implements its own dead click detection. + * A dead click indicates a user tapped an interactive element but no UI change occurred. + * + * @param {object} clickEvent The click event data (same shape as trackClick). + * @param {object} [properties] Optional additional properties. + */ + trackDeadClick(clickEvent, properties) { + if (!this._validateClickEvent(clickEvent, "trackDeadClick")) return; + if (!ObjectHelper.isValidOrUndefined(properties)) { + ObjectHelper.raiseError(PARAMS.PROPERTIES); + } + const mergedProperties = { + ...Helper.getMetaData(), + ...properties, + }; + this.mixpanelImpl.trackDeadClick(this.token, clickEvent, mergedProperties); + } + + _validateClickEvent(clickEvent, methodName) { + if (clickEvent == null || typeof clickEvent !== "object") { + MixpanelLogger.warn( + this.token, + `${methodName} failed: clickEvent must be an object` + ); + return false; + } + if (typeof clickEvent.x !== "number" || typeof clickEvent.y !== "number") { + MixpanelLogger.warn( + this.token, + `${methodName} failed: clickEvent.x and clickEvent.y must be numbers` + ); + return false; + } + if (!StringHelper.isValid(clickEvent.elementId)) { + MixpanelLogger.warn( + this.token, + `${methodName} failed: clickEvent.elementId cannot be blank` + ); + return false; + } + return true; + } } /** diff --git a/ios/MixpanelReactNative.m b/ios/MixpanelReactNative.m index ea39e6b8..5061bc18 100644 --- a/ios/MixpanelReactNative.m +++ b/ios/MixpanelReactNative.m @@ -36,6 +36,12 @@ @interface RCT_EXTERN_MODULE(MixpanelReactNative, NSObject) RCT_EXTERN_METHOD(trackScreenLeave:(NSString *)token screenName:(NSString *)screenName properties:(NSDictionary *)properties resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) +RCT_EXTERN_METHOD(trackClick:(NSString *)token clickEvent:(NSDictionary *)clickEvent properties:(NSDictionary *)properties resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(trackRageClick:(NSString *)token clickEvent:(NSDictionary *)clickEvent properties:(NSDictionary *)properties resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) + +RCT_EXTERN_METHOD(trackDeadClick:(NSString *)token clickEvent:(NSDictionary *)clickEvent properties:(NSDictionary *)properties resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) + // MARK: - Timing Events RCT_EXTERN_METHOD(timeEvent:(NSString *)token event:(NSString *)event resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) diff --git a/ios/MixpanelReactNative.swift b/ios/MixpanelReactNative.swift index 64b447ac..84c4cf55 100644 --- a/ios/MixpanelReactNative.swift +++ b/ios/MixpanelReactNative.swift @@ -235,6 +235,57 @@ open class MixpanelReactNative: NSObject { resolve(nil) } + @objc + func trackClick(_ token: String, clickEvent: [String: Any], + properties: [String: Any]? = nil, + resolver resolve: RCTPromiseResolveBlock, + rejecter reject: RCTPromiseRejectBlock) -> Void { + let instance = MixpanelReactNative.getMixpanelInstance(token) + let event = buildClickEvent(from: clickEvent) + let mpProperties = MixpanelTypeHandler.processProperties(properties: properties) + instance?.autocapture.trackClick(event, properties: mpProperties) + resolve(nil) + } + + @objc + func trackRageClick(_ token: String, clickEvent: [String: Any], + properties: [String: Any]? = nil, + resolver resolve: RCTPromiseResolveBlock, + rejecter reject: RCTPromiseRejectBlock) -> Void { + let instance = MixpanelReactNative.getMixpanelInstance(token) + let event = buildClickEvent(from: clickEvent) + let mpProperties = MixpanelTypeHandler.processProperties(properties: properties) + instance?.autocapture.trackRageClick(event, properties: mpProperties) + resolve(nil) + } + + @objc + func trackDeadClick(_ token: String, clickEvent: [String: Any], + properties: [String: Any]? = nil, + resolver resolve: RCTPromiseResolveBlock, + rejecter reject: RCTPromiseRejectBlock) -> Void { + let instance = MixpanelReactNative.getMixpanelInstance(token) + let event = buildClickEvent(from: clickEvent) + let mpProperties = MixpanelTypeHandler.processProperties(properties: properties) + instance?.autocapture.trackDeadClick(event, properties: mpProperties) + resolve(nil) + } + + private func buildClickEvent(from map: [String: Any]) -> ClickEvent { + let x = map["x"] as? CGFloat ?? 0 + let y = map["y"] as? CGFloat ?? 0 + let elementId = map["elementId"] as? String ?? "" + return ClickEvent( + x: x, + y: y, + elementId: elementId, + tagName: map["tagName"] as? String, + accessibleLabel: map["accessibleLabel"] as? String, + role: map["role"] as? String, + elements: map["elements"] as? String + ) + } + // MARK: - Timing Events @objc From 095843b6db19f407b20c8e4fe6d0f53a205416d3 Mon Sep 17 00:00:00 2001 From: Rahul Raveendran V P Date: Thu, 30 Jul 2026 16:26:49 +0530 Subject: [PATCH 05/10] refactor(autocapture): replace native bridge with pure JS for all autocapture methods Move trackScreenView, trackScreenLeave, trackClick, trackRageClick, and trackDeadClick to pure JS implementations that call track() directly with the correct event names and property mappings. This eliminates 5 native bridge methods on each platform (Android + iOS) and their ObjC declarations, since none of these methods require native view access. Co-Authored-By: Claude Sonnet 4.6 --- .../MixpanelReactNativeModule.java | 109 ------------------ index.js | 70 ++++++----- ios/MixpanelReactNative.m | 12 -- ios/MixpanelReactNative.swift | 73 ------------ 4 files changed, 41 insertions(+), 223 deletions(-) diff --git a/android/src/main/java/com/mixpanel/reactnative/MixpanelReactNativeModule.java b/android/src/main/java/com/mixpanel/reactnative/MixpanelReactNativeModule.java index ba999ab7..d6f8e8e7 100644 --- a/android/src/main/java/com/mixpanel/reactnative/MixpanelReactNativeModule.java +++ b/android/src/main/java/com/mixpanel/reactnative/MixpanelReactNativeModule.java @@ -1,6 +1,5 @@ package com.mixpanel.reactnative; -import com.mixpanel.android.autocapture.ClickEvent; import com.mixpanel.android.mpmetrics.AutocaptureOptions; import com.mixpanel.android.mpmetrics.ClickOptions; import com.mixpanel.android.mpmetrics.DeadClickOptions; @@ -322,114 +321,6 @@ public void track(final String token, final String eventName, ReadableMap proper } } - @ReactMethod - public void trackScreenView(final String token, final String screenName, ReadableMap properties, Promise promise) throws JSONException { - MixpanelAPI instance = MixpanelAPI.getInstance(this.mReactContext, token, true); - if (instance == null) { - promise.reject("Instance Error", "Failed to get Mixpanel instance"); - return; - } - synchronized (instance) { - JSONObject eventProperties = ReactNativeHelper.reactToJSON(properties); - AutomaticProperties.appendLibraryProperties(eventProperties); - if (instance.getAutocapture() != null) { - instance.getAutocapture().trackScreenView(screenName, eventProperties); - } - promise.resolve(null); - } - } - - @ReactMethod - public void trackScreenLeave(final String token, final String screenName, ReadableMap properties, Promise promise) throws JSONException { - MixpanelAPI instance = MixpanelAPI.getInstance(this.mReactContext, token, true); - if (instance == null) { - promise.reject("Instance Error", "Failed to get Mixpanel instance"); - return; - } - synchronized (instance) { - JSONObject eventProperties = ReactNativeHelper.reactToJSON(properties); - AutomaticProperties.appendLibraryProperties(eventProperties); - if (instance.getAutocapture() != null) { - instance.getAutocapture().trackScreenLeave(screenName, eventProperties); - } - promise.resolve(null); - } - } - - @ReactMethod - public void trackClick(final String token, ReadableMap clickEventMap, ReadableMap properties, Promise promise) throws JSONException { - MixpanelAPI instance = MixpanelAPI.getInstance(this.mReactContext, token, true); - if (instance == null) { - promise.reject("Instance Error", "Failed to get Mixpanel instance"); - return; - } - synchronized (instance) { - ClickEvent clickEvent = buildClickEvent(clickEventMap); - JSONObject eventProperties = ReactNativeHelper.reactToJSON(properties); - AutomaticProperties.appendLibraryProperties(eventProperties); - if (instance.getAutocapture() != null) { - instance.getAutocapture().trackClick(clickEvent, eventProperties); - } - promise.resolve(null); - } - } - - @ReactMethod - public void trackRageClick(final String token, ReadableMap clickEventMap, ReadableMap properties, Promise promise) throws JSONException { - MixpanelAPI instance = MixpanelAPI.getInstance(this.mReactContext, token, true); - if (instance == null) { - promise.reject("Instance Error", "Failed to get Mixpanel instance"); - return; - } - synchronized (instance) { - ClickEvent clickEvent = buildClickEvent(clickEventMap); - JSONObject eventProperties = ReactNativeHelper.reactToJSON(properties); - AutomaticProperties.appendLibraryProperties(eventProperties); - if (instance.getAutocapture() != null) { - instance.getAutocapture().trackRageClick(clickEvent, eventProperties); - } - promise.resolve(null); - } - } - - @ReactMethod - public void trackDeadClick(final String token, ReadableMap clickEventMap, ReadableMap properties, Promise promise) throws JSONException { - MixpanelAPI instance = MixpanelAPI.getInstance(this.mReactContext, token, true); - if (instance == null) { - promise.reject("Instance Error", "Failed to get Mixpanel instance"); - return; - } - synchronized (instance) { - ClickEvent clickEvent = buildClickEvent(clickEventMap); - JSONObject eventProperties = ReactNativeHelper.reactToJSON(properties); - AutomaticProperties.appendLibraryProperties(eventProperties); - if (instance.getAutocapture() != null) { - instance.getAutocapture().trackDeadClick(clickEvent, eventProperties); - } - promise.resolve(null); - } - } - - private ClickEvent buildClickEvent(ReadableMap map) { - float x = (float) map.getDouble("x"); - float y = (float) map.getDouble("y"); - String elementId = map.getString("elementId"); - ClickEvent.Builder builder = new ClickEvent.Builder(x, y, elementId); - if (map.hasKey("tagName")) { - builder.tagName(map.getString("tagName")); - } - if (map.hasKey("accessibleLabel")) { - builder.accessibleLabel(map.getString("accessibleLabel")); - } - if (map.hasKey("role")) { - builder.role(map.getString("role")); - } - if (map.hasKey("elements")) { - builder.elements(map.getString("elements")); - } - return builder.build(); - } - @ReactMethod public void registerSuperProperties(final String token, ReadableMap properties, Promise promise) throws JSONException { MixpanelAPI instance = MixpanelAPI.getInstance(this.mReactContext, token, true); diff --git a/index.js b/index.js index a044a9ad..a94aa332 100644 --- a/index.js +++ b/index.js @@ -749,7 +749,7 @@ export class Autocapture { * Track a screen view event. * * @param {string} screenName The name of the screen being viewed - * @param {object} properties Optional additional properties to include with the event + * @param {object} [properties] Optional additional properties to include with the event */ trackScreenView(screenName, properties) { if (!StringHelper.isValid(screenName)) { @@ -762,18 +762,17 @@ export class Autocapture { if (!ObjectHelper.isValidOrUndefined(properties)) { ObjectHelper.raiseError(PARAMS.PROPERTIES); } - const mergedProperties = { - ...Helper.getMetaData(), + this._trackAutocaptureEvent("$mp_page_view", { + current_page_title: screenName, ...properties, - }; - this.mixpanelImpl.trackScreenView(this.token, screenName, mergedProperties); + }); } /** * Track a screen leave event. * * @param {string} screenName The name of the screen being left - * @param {object} properties Optional additional properties to include with the event + * @param {object} [properties] Optional additional properties to include with the event */ trackScreenLeave(screenName, properties) { if (!StringHelper.isValid(screenName)) { @@ -786,11 +785,10 @@ export class Autocapture { if (!ObjectHelper.isValidOrUndefined(properties)) { ObjectHelper.raiseError(PARAMS.PROPERTIES); } - const mergedProperties = { - ...Helper.getMetaData(), + this._trackAutocaptureEvent("$mp_page_leave", { + current_page_title: screenName, ...properties, - }; - this.mixpanelImpl.trackScreenLeave(this.token, screenName, mergedProperties); + }); } /** @@ -811,14 +809,7 @@ export class Autocapture { */ trackClick(clickEvent, properties) { if (!this._validateClickEvent(clickEvent, "trackClick")) return; - if (!ObjectHelper.isValidOrUndefined(properties)) { - ObjectHelper.raiseError(PARAMS.PROPERTIES); - } - const mergedProperties = { - ...Helper.getMetaData(), - ...properties, - }; - this.mixpanelImpl.trackClick(this.token, clickEvent, mergedProperties); + this._trackClickEvent("$mp_click", clickEvent, properties); } /** @@ -832,14 +823,7 @@ export class Autocapture { */ trackRageClick(clickEvent, properties) { if (!this._validateClickEvent(clickEvent, "trackRageClick")) return; - if (!ObjectHelper.isValidOrUndefined(properties)) { - ObjectHelper.raiseError(PARAMS.PROPERTIES); - } - const mergedProperties = { - ...Helper.getMetaData(), - ...properties, - }; - this.mixpanelImpl.trackRageClick(this.token, clickEvent, mergedProperties); + this._trackClickEvent("$mp_rage_click", clickEvent, properties); } /** @@ -853,14 +837,42 @@ export class Autocapture { */ trackDeadClick(clickEvent, properties) { if (!this._validateClickEvent(clickEvent, "trackDeadClick")) return; + this._trackClickEvent("$mp_dead_click", clickEvent, properties); + } + + _trackClickEvent(eventName, clickEvent, properties) { if (!ObjectHelper.isValidOrUndefined(properties)) { ObjectHelper.raiseError(PARAMS.PROPERTIES); } - const mergedProperties = { + const clickProperties = { + $x: clickEvent.x, + $y: clickEvent.y, + $el_id: clickEvent.elementId, + }; + if (clickEvent.tagName != null) { + clickProperties.$el_tag_name = clickEvent.tagName; + } + if (clickEvent.accessibleLabel != null) { + clickProperties["$attr-aria-label"] = clickEvent.accessibleLabel; + } + if (clickEvent.role != null) { + clickProperties["$attr-role"] = clickEvent.role; + } + if (clickEvent.elements != null) { + clickProperties.$elements = clickEvent.elements; + } + this._trackAutocaptureEvent(eventName, { + ...clickProperties, + ...properties, + }); + } + + _trackAutocaptureEvent(eventName, properties) { + this.mixpanelImpl.track(this.token, eventName, { ...Helper.getMetaData(), + $mp_autocapture: true, ...properties, - }; - this.mixpanelImpl.trackDeadClick(this.token, clickEvent, mergedProperties); + }); } _validateClickEvent(clickEvent, methodName) { diff --git a/ios/MixpanelReactNative.m b/ios/MixpanelReactNative.m index 5061bc18..a0ea2953 100644 --- a/ios/MixpanelReactNative.m +++ b/ios/MixpanelReactNative.m @@ -30,18 +30,6 @@ @interface RCT_EXTERN_MODULE(MixpanelReactNative, NSObject) RCT_EXTERN_METHOD(track:(NSString *)token event:(NSString *)event properties:(NSDictionary *)properties resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) -// MARK: - Autocapture - -RCT_EXTERN_METHOD(trackScreenView:(NSString *)token screenName:(NSString *)screenName properties:(NSDictionary *)properties resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) - -RCT_EXTERN_METHOD(trackScreenLeave:(NSString *)token screenName:(NSString *)screenName properties:(NSDictionary *)properties resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) - -RCT_EXTERN_METHOD(trackClick:(NSString *)token clickEvent:(NSDictionary *)clickEvent properties:(NSDictionary *)properties resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) - -RCT_EXTERN_METHOD(trackRageClick:(NSString *)token clickEvent:(NSDictionary *)clickEvent properties:(NSDictionary *)properties resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) - -RCT_EXTERN_METHOD(trackDeadClick:(NSString *)token clickEvent:(NSDictionary *)clickEvent properties:(NSDictionary *)properties resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) - // MARK: - Timing Events RCT_EXTERN_METHOD(timeEvent:(NSString *)token event:(NSString *)event resolver:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) diff --git a/ios/MixpanelReactNative.swift b/ios/MixpanelReactNative.swift index 84c4cf55..d125f3a0 100644 --- a/ios/MixpanelReactNative.swift +++ b/ios/MixpanelReactNative.swift @@ -213,79 +213,6 @@ open class MixpanelReactNative: NSObject { // MARK: - Autocapture - @objc - func trackScreenView(_ token: String, screenName: String, - properties: [String: Any]? = nil, - resolver resolve: RCTPromiseResolveBlock, - rejecter reject: RCTPromiseRejectBlock) -> Void { - let instance = MixpanelReactNative.getMixpanelInstance(token) - let mpProperties = MixpanelTypeHandler.processProperties(properties: properties) - instance?.autocapture.trackScreenView(screenName: screenName, properties: mpProperties) - resolve(nil) - } - - @objc - func trackScreenLeave(_ token: String, screenName: String, - properties: [String: Any]? = nil, - resolver resolve: RCTPromiseResolveBlock, - rejecter reject: RCTPromiseRejectBlock) -> Void { - let instance = MixpanelReactNative.getMixpanelInstance(token) - let mpProperties = MixpanelTypeHandler.processProperties(properties: properties) - instance?.autocapture.trackScreenLeave(screenName: screenName, properties: mpProperties) - resolve(nil) - } - - @objc - func trackClick(_ token: String, clickEvent: [String: Any], - properties: [String: Any]? = nil, - resolver resolve: RCTPromiseResolveBlock, - rejecter reject: RCTPromiseRejectBlock) -> Void { - let instance = MixpanelReactNative.getMixpanelInstance(token) - let event = buildClickEvent(from: clickEvent) - let mpProperties = MixpanelTypeHandler.processProperties(properties: properties) - instance?.autocapture.trackClick(event, properties: mpProperties) - resolve(nil) - } - - @objc - func trackRageClick(_ token: String, clickEvent: [String: Any], - properties: [String: Any]? = nil, - resolver resolve: RCTPromiseResolveBlock, - rejecter reject: RCTPromiseRejectBlock) -> Void { - let instance = MixpanelReactNative.getMixpanelInstance(token) - let event = buildClickEvent(from: clickEvent) - let mpProperties = MixpanelTypeHandler.processProperties(properties: properties) - instance?.autocapture.trackRageClick(event, properties: mpProperties) - resolve(nil) - } - - @objc - func trackDeadClick(_ token: String, clickEvent: [String: Any], - properties: [String: Any]? = nil, - resolver resolve: RCTPromiseResolveBlock, - rejecter reject: RCTPromiseRejectBlock) -> Void { - let instance = MixpanelReactNative.getMixpanelInstance(token) - let event = buildClickEvent(from: clickEvent) - let mpProperties = MixpanelTypeHandler.processProperties(properties: properties) - instance?.autocapture.trackDeadClick(event, properties: mpProperties) - resolve(nil) - } - - private func buildClickEvent(from map: [String: Any]) -> ClickEvent { - let x = map["x"] as? CGFloat ?? 0 - let y = map["y"] as? CGFloat ?? 0 - let elementId = map["elementId"] as? String ?? "" - return ClickEvent( - x: x, - y: y, - elementId: elementId, - tagName: map["tagName"] as? String, - accessibleLabel: map["accessibleLabel"] as? String, - role: map["role"] as? String, - elements: map["elements"] as? String - ) - } - // MARK: - Timing Events @objc From 8b86a7137844cc21c2884d621b71b2cd12072246 Mon Sep 17 00:00:00 2001 From: Rahul Raveendran V P Date: Thu, 30 Jul 2026 21:33:21 +0530 Subject: [PATCH 06/10] feat(autocapture): make walkUpToClickableParent configurable from JS Expose walkUpToClickableParent as a top-level AutocaptureOptions field (default true) instead of hardcoding it in the native bridge. Developers can now disable it if their React Native app has proper accessibility identifiers on leaf views. Co-Authored-By: Claude Sonnet 4.6 --- .../mixpanel/reactnative/MixpanelReactNativeModule.java | 3 ++- index.d.ts | 7 +++++++ index.js | 6 ++++++ ios/MixpanelReactNative.swift | 4 +++- 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/android/src/main/java/com/mixpanel/reactnative/MixpanelReactNativeModule.java b/android/src/main/java/com/mixpanel/reactnative/MixpanelReactNativeModule.java index d6f8e8e7..256b8cf7 100644 --- a/android/src/main/java/com/mixpanel/reactnative/MixpanelReactNativeModule.java +++ b/android/src/main/java/com/mixpanel/reactnative/MixpanelReactNativeModule.java @@ -100,7 +100,8 @@ public void initialize(String token, boolean trackAutomaticEvents, boolean optOu private AutocaptureOptions buildAutocaptureOptions(ReadableMap config) { AutocaptureOptions.Builder builder = new AutocaptureOptions.Builder(); - builder.walkUpToClickableParent(true); + boolean walkUp = !config.hasKey("walkUpToClickableParent") || config.getBoolean("walkUpToClickableParent"); + builder.walkUpToClickableParent(walkUp); if (config.hasKey("click")) { ReadableMap clickConfig = config.getMap("click"); diff --git a/index.d.ts b/index.d.ts index dd40abb9..8b28cdd2 100644 --- a/index.d.ts +++ b/index.d.ts @@ -123,6 +123,13 @@ export interface AutocaptureOptions { click?: boolean | AutocaptureClickOptions; rageClick?: boolean | AutocaptureRageClickOptions; deadClick?: boolean | AutocaptureDeadClickOptions; + /** + * When enabled, if the tapped view has no meaningful identifier, the SDK + * walks up the view hierarchy to the nearest clickable ancestor and uses + * its identity instead. Affects `$el_id` on all autocapture events. + * Defaults to `true`. + */ + walkUpToClickableParent?: boolean; } export interface ClickEventData { diff --git a/index.js b/index.js index a94aa332..ea480a44 100644 --- a/index.js +++ b/index.js @@ -1326,6 +1326,12 @@ class AutocaptureHelper { normalized.deadClick = { enabled: true }; } + // walkUpToClickableParent — default true for React Native + normalized.walkUpToClickableParent = + options.walkUpToClickableParent !== undefined + ? !!options.walkUpToClickableParent + : true; + return normalized; } } diff --git a/ios/MixpanelReactNative.swift b/ios/MixpanelReactNative.swift index d125f3a0..dda20b11 100644 --- a/ios/MixpanelReactNative.swift +++ b/ios/MixpanelReactNative.swift @@ -90,11 +90,13 @@ open class MixpanelReactNative: NSObject { ) } + let walkUp = config["walkUpToClickableParent"] as? Bool ?? true + return AutocaptureOptions( clickOptions: clickOpts, rageClickOptions: rageClickOpts, deadClickOptions: deadClickOpts, - walkUpToClickableParent: true + walkUpToClickableParent: walkUp ) } From 8080bcf5cc17120168d4795cca4b391515262035 Mon Sep 17 00:00:00 2001 From: Rahul Raveendran V P Date: Fri, 31 Jul 2026 15:05:57 +0530 Subject: [PATCH 07/10] docs(autocapture): add @example blocks to all public autocapture APIs Add missing documentation examples for trackScreenView, trackScreenLeave, trackClick, trackRageClick, trackDeadClick, and autocapture init options including walkUpToClickableParent. Co-Authored-By: Claude Sonnet 4.6 --- index.js | 51 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 51 insertions(+) diff --git a/index.js b/index.js index ea480a44..e2c764c4 100644 --- a/index.js +++ b/index.js @@ -148,6 +148,9 @@ export class Mixpanel { * Object form: {enabled, clickThreshold, timeWindowMs, radius} * @param {boolean|object} [autocaptureOptions.deadClick=true] Enable dead click detection. * Object form: {enabled, timeWindowMs} + * @param {boolean} [autocaptureOptions.walkUpToClickableParent=true] When the tapped view has no + * meaningful identifier, walk up the view hierarchy to the nearest clickable ancestor and use + * its identity for $el_id. Affects all autocapture click events. * @returns {Promise} A promise that resolves when initialization is complete * * @example @@ -176,6 +179,16 @@ export class Mixpanel { * 'https://api-eu.mixpanel.com', * true * ); + * + * @example + * // Initialize with autocapture enabled (native mode required) + * const mixpanel = new Mixpanel('YOUR_TOKEN', true, true); + * await mixpanel.init(false, {}, 'https://api.mixpanel.com', false, {}, { + * click: true, + * rageClick: { enabled: true, clickThreshold: 5, timeWindowMs: 2000 }, + * deadClick: { enabled: true, timeWindowMs: 1000 }, + * walkUpToClickableParent: true, + * }); */ async init( optOutTrackingDefault = DEFAULT_OPT_OUT, @@ -750,6 +763,10 @@ export class Autocapture { * * @param {string} screenName The name of the screen being viewed * @param {object} [properties] Optional additional properties to include with the event + * + * @example + * mixpanel.autocapture.trackScreenView('HomeScreen'); + * mixpanel.autocapture.trackScreenView('ProductDetail', { product_id: '123' }); */ trackScreenView(screenName, properties) { if (!StringHelper.isValid(screenName)) { @@ -773,6 +790,10 @@ export class Autocapture { * * @param {string} screenName The name of the screen being left * @param {object} [properties] Optional additional properties to include with the event + * + * @example + * mixpanel.autocapture.trackScreenLeave('HomeScreen'); + * mixpanel.autocapture.trackScreenLeave('ProductDetail', { time_spent_ms: 5000 }); */ trackScreenLeave(screenName, properties) { if (!StringHelper.isValid(screenName)) { @@ -806,6 +827,17 @@ export class Autocapture { * @param {string} [clickEvent.role] Semantic role (e.g., "button", "link"). * @param {string} [clickEvent.elements] View hierarchy path, ">" separated. * @param {object} [properties] Optional additional properties. + * + * @example + * mixpanel.autocapture.trackClick({ + * x: 150, + * y: 300, + * elementId: 'submit_button', + * tagName: 'Button', + * accessibleLabel: 'Submit Order', + * role: 'button', + * elements: 'Screen > Form > Button', + * }); */ trackClick(clickEvent, properties) { if (!this._validateClickEvent(clickEvent, "trackClick")) return; @@ -820,6 +852,15 @@ export class Autocapture { * * @param {object} clickEvent The click event data (same shape as trackClick). * @param {object} [properties] Optional additional properties. + * + * @example + * mixpanel.autocapture.trackRageClick({ + * x: 150, + * y: 300, + * elementId: 'checkout_button', + * tagName: 'Button', + * role: 'button', + * }); */ trackRageClick(clickEvent, properties) { if (!this._validateClickEvent(clickEvent, "trackRageClick")) return; @@ -834,6 +875,16 @@ export class Autocapture { * * @param {object} clickEvent The click event data (same shape as trackClick). * @param {object} [properties] Optional additional properties. + * + * @example + * mixpanel.autocapture.trackDeadClick({ + * x: 200, + * y: 400, + * elementId: 'disabled_link', + * tagName: 'Text', + * accessibleLabel: 'Learn More', + * role: 'link', + * }); */ trackDeadClick(clickEvent, properties) { if (!this._validateClickEvent(clickEvent, "trackDeadClick")) return; From 524b6b93f9c90eec483fec047a12cd66adaede79 Mon Sep 17 00:00:00 2001 From: Rahul Raveendran V P Date: Mon, 3 Aug 2026 12:03:31 +0530 Subject: [PATCH 08/10] refactor(autocapture): remove walkUpToClickableParent option and add test screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove walkUpToClickableParent from AutocaptureOptions — it is now always-on in the native SDKs. Add WalkUpTestScreen to MixpanelStarter sample app. Co-Authored-By: Claude Sonnet 4.6 --- Samples/MixpanelStarter/src/App.tsx | 24 +- .../src/screens/WalkUpTestScreen.tsx | 225 ++++++++++++++++++ .../MixpanelReactNativeModule.java | 3 - index.d.ts | 7 - index.js | 10 - ios/MixpanelReactNative.swift | 5 +- 6 files changed, 249 insertions(+), 25 deletions(-) create mode 100644 Samples/MixpanelStarter/src/screens/WalkUpTestScreen.tsx diff --git a/Samples/MixpanelStarter/src/App.tsx b/Samples/MixpanelStarter/src/App.tsx index a7b294ec..e744eebc 100644 --- a/Samples/MixpanelStarter/src/App.tsx +++ b/Samples/MixpanelStarter/src/App.tsx @@ -11,6 +11,8 @@ import {OnboardingScreen} from './screens/OnboardingScreen'; import {HomeScreen} from './screens/HomeScreen'; import {FeatureFlagsScreen} from './screens/FeatureFlagsScreen'; import {SettingsScreen} from './screens/SettingsScreen'; +import {AutocaptureTestScreen} from './screens/AutocaptureTestScreen'; +import {WalkUpTestScreen} from './screens/WalkUpTestScreen'; import {MIXPANEL_TOKEN} from '@env'; const Tab = createBottomTabNavigator(); @@ -112,6 +114,26 @@ function AppNavigator(): React.JSX.Element { ), }} /> + ( + tap + ), + }} + /> + ( + 🔍 + ), + }} + /> - + diff --git a/Samples/MixpanelStarter/src/screens/WalkUpTestScreen.tsx b/Samples/MixpanelStarter/src/screens/WalkUpTestScreen.tsx new file mode 100644 index 00000000..24ae3452 --- /dev/null +++ b/Samples/MixpanelStarter/src/screens/WalkUpTestScreen.tsx @@ -0,0 +1,225 @@ +import React from 'react'; +import { + ScrollView, + View, + Text, + Pressable, + TouchableOpacity, + StyleSheet, +} from 'react-native'; + +/** + * Walk-Up-to-Clickable-Parent Test Screen + * + * Validates that when a non-interactive leaf view (Text, Image) is tapped, + * the native SDK walks up to the nearest clickable ancestor (Pressable, + * TouchableOpacity) for $el_id resolution. + * + * Run on both iOS and Android. Inspect Mixpanel event stream to verify. + */ +export function WalkUpTestScreen() { + return ( + + Walk-Up Test Screen + + Tests that tapping a leaf view (Text/Image) inside a clickable wrapper + reports the wrapper's identity as $el_id, not the leaf's hash. + + + {/* 1. Basic Walk-Up */} + + + Tap the text. $el_id should be "add_to_cart" from the Pressable. + + {}} + accessible={true} + accessibilityLabel="add_to_cart"> + Add to Cart + + + {/* 2. Nested Pressables */} + + + Tap "Delete". Walk-up stops at inner Pressable ("delete_item"), not + outer card ("product_card"). + + {}} + accessible={true} + accessibilityLabel="product_card"> + Product Name + {}} + accessible={true} + accessibilityLabel="delete_item"> + Delete + + + + {/* 3. Pressable with Image + Text */} + + + Tap the icon or text. $el_id should be "checkout_action". + + {}} + accessible={true} + accessibilityLabel="checkout_action"> + 🛒 + Proceed to Checkout + + + {/* 4. Non-interactive text */} + + + Tap below. No Pressable ancestor exists. $el_id = hash fallback. + + + Terms and Conditions apply. + + + {/* 5. Leaf with own identity */} + + + Tap the text. It has its own accessibilityLabel ("inner_label"), so + walk-up should NOT activate. $el_id = "inner_label". + + {}} + accessible={true} + accessibilityLabel="outer_button"> + + I have my own identity + + + + {/* 6. View flattening */} + + + Intermediate View has no visual props and will be flattened away. Tap the + text — walk-up should still find "flattened_pressable". + + {}} + accessible={true} + accessibilityLabel="flattened_pressable"> + + Text inside flattened View + + + + {/* 7. TouchableOpacity variant */} + + + Same as basic walk-up but with TouchableOpacity. $el_id should be + "touchable_btn". + + {}} + accessible={true} + accessibilityLabel="touchable_btn"> + TouchableOpacity Button + + + ); +} + +function SectionHeader({title}: {title: string}) { + return {title}; +} + +const styles = StyleSheet.create({ + container: { + padding: 16, + paddingBottom: 48, + }, + title: { + fontSize: 22, + fontWeight: 'bold', + marginBottom: 4, + }, + subtitle: { + fontSize: 13, + color: '#666', + marginBottom: 16, + }, + sectionHeader: { + fontSize: 17, + fontWeight: '700', + marginTop: 20, + marginBottom: 4, + color: '#333', + }, + description: { + fontSize: 12, + color: '#888', + marginBottom: 6, + }, + btn: { + backgroundColor: '#2196F3', + padding: 14, + borderRadius: 8, + alignItems: 'center', + marginBottom: 8, + }, + btnText: { + color: '#fff', + fontSize: 15, + fontWeight: '600', + }, + card: { + backgroundColor: '#f5f5f5', + borderRadius: 12, + padding: 16, + marginBottom: 8, + }, + cardTitle: { + fontSize: 16, + fontWeight: 'bold', + marginBottom: 8, + }, + deleteBtn: { + backgroundColor: '#f44336', + padding: 10, + borderRadius: 8, + alignItems: 'center', + }, + deleteBtnText: { + color: '#fff', + fontWeight: '600', + }, + row: { + flexDirection: 'row', + alignItems: 'center', + padding: 16, + backgroundColor: '#f5f5f5', + borderRadius: 8, + marginBottom: 8, + }, + icon: { + fontSize: 20, + marginRight: 12, + }, + rowText: { + fontSize: 16, + }, + plainTextContainer: { + padding: 8, + marginBottom: 8, + }, + plainText: { + fontSize: 14, + color: '#888', + }, +}); diff --git a/android/src/main/java/com/mixpanel/reactnative/MixpanelReactNativeModule.java b/android/src/main/java/com/mixpanel/reactnative/MixpanelReactNativeModule.java index 256b8cf7..3069af64 100644 --- a/android/src/main/java/com/mixpanel/reactnative/MixpanelReactNativeModule.java +++ b/android/src/main/java/com/mixpanel/reactnative/MixpanelReactNativeModule.java @@ -100,9 +100,6 @@ public void initialize(String token, boolean trackAutomaticEvents, boolean optOu private AutocaptureOptions buildAutocaptureOptions(ReadableMap config) { AutocaptureOptions.Builder builder = new AutocaptureOptions.Builder(); - boolean walkUp = !config.hasKey("walkUpToClickableParent") || config.getBoolean("walkUpToClickableParent"); - builder.walkUpToClickableParent(walkUp); - if (config.hasKey("click")) { ReadableMap clickConfig = config.getMap("click"); if (clickConfig != null) { diff --git a/index.d.ts b/index.d.ts index 8b28cdd2..dd40abb9 100644 --- a/index.d.ts +++ b/index.d.ts @@ -123,13 +123,6 @@ export interface AutocaptureOptions { click?: boolean | AutocaptureClickOptions; rageClick?: boolean | AutocaptureRageClickOptions; deadClick?: boolean | AutocaptureDeadClickOptions; - /** - * When enabled, if the tapped view has no meaningful identifier, the SDK - * walks up the view hierarchy to the nearest clickable ancestor and uses - * its identity instead. Affects `$el_id` on all autocapture events. - * Defaults to `true`. - */ - walkUpToClickableParent?: boolean; } export interface ClickEventData { diff --git a/index.js b/index.js index e2c764c4..d58f0b60 100644 --- a/index.js +++ b/index.js @@ -148,9 +148,6 @@ export class Mixpanel { * Object form: {enabled, clickThreshold, timeWindowMs, radius} * @param {boolean|object} [autocaptureOptions.deadClick=true] Enable dead click detection. * Object form: {enabled, timeWindowMs} - * @param {boolean} [autocaptureOptions.walkUpToClickableParent=true] When the tapped view has no - * meaningful identifier, walk up the view hierarchy to the nearest clickable ancestor and use - * its identity for $el_id. Affects all autocapture click events. * @returns {Promise} A promise that resolves when initialization is complete * * @example @@ -187,7 +184,6 @@ export class Mixpanel { * click: true, * rageClick: { enabled: true, clickThreshold: 5, timeWindowMs: 2000 }, * deadClick: { enabled: true, timeWindowMs: 1000 }, - * walkUpToClickableParent: true, * }); */ async init( @@ -1377,12 +1373,6 @@ class AutocaptureHelper { normalized.deadClick = { enabled: true }; } - // walkUpToClickableParent — default true for React Native - normalized.walkUpToClickableParent = - options.walkUpToClickableParent !== undefined - ? !!options.walkUpToClickableParent - : true; - return normalized; } } diff --git a/ios/MixpanelReactNative.swift b/ios/MixpanelReactNative.swift index dda20b11..eefeac73 100644 --- a/ios/MixpanelReactNative.swift +++ b/ios/MixpanelReactNative.swift @@ -90,13 +90,10 @@ open class MixpanelReactNative: NSObject { ) } - let walkUp = config["walkUpToClickableParent"] as? Bool ?? true - return AutocaptureOptions( clickOptions: clickOpts, rageClickOptions: rageClickOpts, - deadClickOptions: deadClickOpts, - walkUpToClickableParent: walkUp + deadClickOptions: deadClickOpts ) } From 6222274be1d7afe8d1050673b38c9d1e1d4a0311 Mon Sep 17 00:00:00 2001 From: Rahul Raveendran V P Date: Mon, 3 Aug 2026 13:36:59 +0530 Subject: [PATCH 09/10] fix(demo): update walk-up test to reflect that leaf identity is overridden by clickable parent Walk-up always takes the nearest clickable ancestor's identity, even when the leaf has its own accessibilityLabel. Co-Authored-By: Claude Sonnet 4.6 --- Samples/MixpanelStarter/src/screens/WalkUpTestScreen.tsx | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/Samples/MixpanelStarter/src/screens/WalkUpTestScreen.tsx b/Samples/MixpanelStarter/src/screens/WalkUpTestScreen.tsx index 24ae3452..77d86d1f 100644 --- a/Samples/MixpanelStarter/src/screens/WalkUpTestScreen.tsx +++ b/Samples/MixpanelStarter/src/screens/WalkUpTestScreen.tsx @@ -86,8 +86,9 @@ export function WalkUpTestScreen() { {/* 5. Leaf with own identity */} - Tap the text. It has its own accessibilityLabel ("inner_label"), so - walk-up should NOT activate. $el_id = "inner_label". + Tap the text. Even though it has its own accessibilityLabel + ("inner_label"), walk-up still activates and takes the clickable + parent's identity. $el_id = "outer_button". Date: Mon, 3 Aug 2026 15:12:57 +0530 Subject: [PATCH 10/10] docs: add autocapture context doc with walk-up-to-clickable-parent behavior Co-Authored-By: Claude Sonnet 4.6 --- context/AUTOCAPTURE.md | 69 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 69 insertions(+) create mode 100644 context/AUTOCAPTURE.md diff --git a/context/AUTOCAPTURE.md b/context/AUTOCAPTURE.md new file mode 100644 index 00000000..de64c17c --- /dev/null +++ b/context/AUTOCAPTURE.md @@ -0,0 +1,69 @@ +# React Native Autocapture + +Autocapture automatically tracks user interactions in React Native apps by delegating to the native Android and iOS SDKs. + +## Overview + +Autocapture captures three types of events: + +| Event | Name | Description | +|-------|------|-------------| +| Click | `$mp_click` | Fired when a user taps any element | +| Rage Click | `$mp_rage_click` | Fired when a user taps rapidly (4+ times) in the same area | +| Dead Click | `$mp_dead_click` | Fired when a tap produces no visible UI response | + +## Quick Start + +```typescript +import {Mixpanel} from 'mixpanel-react-native'; + +const mixpanel = new Mixpanel('YOUR_TOKEN', true); +await mixpanel.init( + false, // optOutTrackingDefault + {}, // superProperties + '', // serverURL + true, // useGzipCompression + {}, // featureFlagsOptions + { // autocaptureOptions + click: true, + rageClick: true, + deadClick: true, + } +); +``` + +## Element Identification (`$el_id`) + +### Walk-Up to Clickable Parent + +When a non-interactive leaf view (e.g., `` inside a ``) is tapped, the native SDK walks up the view hierarchy to the nearest clickable ancestor and uses its `accessibilityLabel` for `$el_id`. This is always-on behavior — not configurable. + +- The walk-up always takes the clickable parent's identity, even if the leaf has its own `accessibilityLabel`. +- Stops at the first clickable ancestor (nested clickables: inner wins). +- Max ancestor search depth: **10 levels**. +- If no clickable ancestor is found within 10 levels, the leaf's own identity (or hash fallback) is used. + +React Native's view flattening compounds this — intermediate `` wrappers are removed from the native tree, so the platform's hit-test often returns a leaf `Text` node even when the developer intended the tap for the parent `Pressable`. + +### Best Practice + +Set `accessibilityLabel` on interactive wrappers (`Pressable`, `TouchableOpacity`): + +```tsx + + Add to Cart + +``` + +## Configuration Options + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `click` | `boolean \| AutocaptureClickOptions` | `true` | Track click events | +| `rageClick` | `boolean \| AutocaptureRageClickOptions` | `true` | Track rage click events | +| `deadClick` | `boolean \| AutocaptureDeadClickOptions` | `true` | Track dead click events | + +See `index.d.ts` for `AutocaptureClickOptions`, `AutocaptureRageClickOptions`, and `AutocaptureDeadClickOptions` interfaces.