From 99ece0b9a619b6c1d1d8bd90786a452852938063 Mon Sep 17 00:00:00 2001 From: Nickolas Dimitrakas Date: Fri, 19 Jun 2026 13:34:28 -0400 Subject: [PATCH 1/4] test(sample): add opt-in deferred-init edge-case example (Android) Adds a self-contained, disabled-by-default example that reproduces the Android late-initialisation Activity-capture race: starting mParticle from a native module at first-frame paint instead of Application.onCreate(). The Rokt SDK (<= v5) caches the current Activity only on onActivityResumed (registered during Rokt.init() via RoktKit.onKitCreate()), so deferring init past the host Activity's resume leaves overlay placements unable to display until the next resume. iOS is unaffected. Fixed upstream in the Rokt Android SDK (ROKT/sdk-android-source #1062, #1063; v5 backport #1082). Default sample behaviour is unchanged (standard eager init). Flip DeferredInitModule.DEFERRED_INIT_EXAMPLE to true to reproduce; the EAGER vs DEFERRED ActivityTracker logs (adb logcat -s DeferredInitRepro) make the race observable. Kept around as an easy regression/verification fixture. Co-Authored-By: Claude Opus 4.8 --- sample/README.md | 21 ++++ .../com/mparticlesample/ActivityTracker.kt | 48 ++++++++ .../com/mparticlesample/DeferredInitModule.kt | 108 ++++++++++++++++++ .../mparticlesample/DeferredInitPackage.kt | 14 +++ .../com/mparticlesample/MainApplication.kt | 29 +++-- sample/index.js | 18 +++ 6 files changed, 229 insertions(+), 9 deletions(-) create mode 100644 sample/android/app/src/main/java/com/mparticlesample/ActivityTracker.kt create mode 100644 sample/android/app/src/main/java/com/mparticlesample/DeferredInitModule.kt create mode 100644 sample/android/app/src/main/java/com/mparticlesample/DeferredInitPackage.kt diff --git a/sample/README.md b/sample/README.md index cc68665..58dca06 100644 --- a/sample/README.md +++ b/sample/README.md @@ -159,6 +159,27 @@ xcodebuild -workspace MParticleSample.xcworkspace \ Pull requests run these tests in CI (see `.github/workflows/pull-request.yml`). +## Deferred-init edge case (Android) + +The sample includes a small, opt-in example that reproduces a late-initialisation race on +Android: starting mParticle from a native module at first-frame paint (a partner pattern used +to cut startup cost) instead of in `MainApplication.onCreate()`. Because the Rokt SDK caches +the current `Activity` only on `onActivityResumed` (≤ v5), deferring init past the host +Activity's resume leaves overlay/bottom-sheet placements unable to display until the next +resume. iOS is unaffected. Fixed upstream in the Rokt Android SDK (`sdk-android-source` +[#1062](https://github.com/ROKT/sdk-android-source/pull/1062), +[#1063](https://github.com/ROKT/sdk-android-source/pull/1063)). + +It is **disabled by default**. To reproduce: + +1. Set `DEFERRED_INIT_EXAMPLE = true` in + `android/app/src/main/java/com/mparticlesample/DeferredInitModule.kt`. +2. Run the app and watch `adb logcat -s DeferredInitRepro`. + +The `EAGER` tracker (registered at process start) captures `MainActivity`; the `DEFERRED` +tracker (registered when init runs at first frame) stays `null` until you background and +reopen the app — demonstrating the race. See `DeferredInitModule.kt` for the full write-up. + ## Additional Resources - [mParticle Documentation](https://docs.mparticle.com/) diff --git a/sample/android/app/src/main/java/com/mparticlesample/ActivityTracker.kt b/sample/android/app/src/main/java/com/mparticlesample/ActivityTracker.kt new file mode 100644 index 0000000..50b794e --- /dev/null +++ b/sample/android/app/src/main/java/com/mparticlesample/ActivityTracker.kt @@ -0,0 +1,48 @@ +package com.mparticlesample + +import android.app.Activity +import android.app.Application +import android.os.Bundle +import android.util.Log +import java.lang.ref.WeakReference + +/** + * Mirrors the Rokt Android SDK's `ActivityLifeCycleObserver` caching strategy: + * the "current activity" is captured ONLY in onActivityResumed. This is the + * mechanism RoktModalActivity (overlay / bottom-sheet placements) depends on. + * + * We register two trackers in the repro: + * - EAGER : registered in Application.onCreate (before MainActivity exists) + * - DEFERRED: registered when MParticle.start() is called at first-frame paint + * + * If the deferred tracker is registered AFTER the host Activity has already + * resumed, it never sees onActivityResumed and currentActivity stays null -- + * exactly the failure ROKT/sdk-android-source#1062 / #1063 fix. + */ +class ActivityTracker(private val label: String) : Application.ActivityLifecycleCallbacks { + + @Volatile + private var currentActivityRef: WeakReference? = null + + val currentActivity: Activity? + get() = currentActivityRef?.get() + + companion object { + const val TAG = "DeferredInitRepro" + } + + override fun onActivityResumed(activity: Activity) { + currentActivityRef = WeakReference(activity) + Log.i(TAG, "[$label] onActivityResumed -> captured ${activity.localClassName}") + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + Log.i(TAG, "[$label] onActivityCreated ${activity.localClassName}") + } + + override fun onActivityStarted(activity: Activity) {} + override fun onActivityPaused(activity: Activity) {} + override fun onActivityStopped(activity: Activity) {} + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + override fun onActivityDestroyed(activity: Activity) {} +} diff --git a/sample/android/app/src/main/java/com/mparticlesample/DeferredInitModule.kt b/sample/android/app/src/main/java/com/mparticlesample/DeferredInitModule.kt new file mode 100644 index 0000000..cc4e5f8 --- /dev/null +++ b/sample/android/app/src/main/java/com/mparticlesample/DeferredInitModule.kt @@ -0,0 +1,108 @@ +package com.mparticlesample + +import android.app.Application +import android.os.Handler +import android.os.Looper +import android.util.Log +import com.facebook.react.bridge.Promise +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.bridge.ReactContextBaseJavaModule +import com.facebook.react.bridge.ReactMethod +import com.mparticle.MParticle +import com.mparticle.MParticleOptions +import com.mparticle.identity.IdentityApiRequest + +/** + * Edge-case example: "deferred initialisation with Turbo Modules". + * + * Instead of calling MParticle.start() in MainApplication.onCreate() (the standard, + * supported integration), JS calls startMParticle() from this native module after the + * first frame is painted. This reproduces a partner pattern used to shave init cost off + * app startup -- and exposes an Android-specific race that does NOT occur on iOS: + * + * The Rokt SDK caches its "current Activity" only in onActivityResumed, via an observer + * registered during Rokt.init() (which mParticle calls from RoktKit.onKitCreate()). When + * init runs AFTER the host Activity has already resumed -- exactly what deferred init does + * -- that resume is missed, so overlay/bottom-sheet placements (RoktModalActivity) have no + * Activity to launch from until the next resume (the "press home and reopen" workaround). + * iOS is immune because it resolves the presenter lazily at execute time. + * + * Fixed upstream in the Rokt Android SDK by observing the lifecycle from process start: + * ROKT/sdk-android-source#1062 and #1063 (v5 backport #1082). This example is kept around + * as an easy way to re-trigger the scenario and confirm the fix / catch regressions. + * + * To enable: flip DEFERRED_INIT_EXAMPLE to true and watch `adb logcat -s DeferredInitRepro` + * -- the DEFERRED tracker's currentActivity stays null while the EAGER one captures it. + * + * (Registered through a legacy ReactPackage; under the New Architecture it is invoked via the + * TurboModule interop layer. The JS call timing -- after first-frame paint -- is identical to + * a pure TurboModule, which is what the race depends on.) + */ +class DeferredInitModule(private val reactContext: ReactApplicationContext) : + ReactContextBaseJavaModule(reactContext) { + + companion object { + const val TAG = "DeferredInitRepro" + + /** + * Master switch for the deferred-init edge case. Keep this `false` so the sample uses the + * standard eager init in MainApplication.onCreate(); set it to `true` to reproduce the + * late-init Activity-capture race described above. + */ + const val DEFERRED_INIT_EXAMPLE = false + + // Registered in Application.onCreate -- the eager path, for contrast. + var eagerTracker: ActivityTracker? = null + } + + // Registered at deferred-start time, mirroring Rokt.init()'s late registration. + private val deferredTracker = ActivityTracker("DEFERRED") + + override fun getName(): String = "DeferredInit" + + @ReactMethod + fun startMParticle(promise: Promise) { + if (!DEFERRED_INIT_EXAMPLE) { + // Standard mode: mParticle was already started in Application.onCreate(); nothing to do. + promise.resolve("eager-init (deferred example disabled)") + return + } + + Log.i(TAG, "startMParticle() called from JS (post first-frame). MParticle.getInstance()=${MParticle.getInstance()}") + + val app = reactContext.applicationContext as Application + + // Mirror Rokt SDK: register the activity observer at init time, NOT at process start. + app.registerActivityLifecycleCallbacks(deferredTracker) + Log.i(TAG, "[DEFERRED] tracker registered. currentActivity right now = ${deferredTracker.currentActivity}") + + val identityRequest = IdentityApiRequest.withEmptyUser() + val options = MParticleOptions.builder(app) + .credentials("REPLACE_ME", "REPLACE_ME") + .logLevel(MParticle.LogLevel.VERBOSE) + .identify(identityRequest.build()) + .build() + + MParticle.start(options) + Log.i(TAG, "MParticle.start() invoked from native module. getInstance()=${MParticle.getInstance()}") + + // Snapshot the captured activity 2s later: did the deferred observer ever + // see a resume? (Rokt's currentActivity cache works exactly this way.) + Handler(Looper.getMainLooper()).postDelayed({ + Log.i( + TAG, + "SNAPSHOT after 2s -> EAGER.currentActivity=${eagerTracker?.currentActivity?.localClassName} | " + + "DEFERRED.currentActivity=${deferredTracker.currentActivity?.localClassName}" + ) + }, 2000) + + promise.resolve("started") + } + + @ReactMethod + fun reportActivityState(promise: Promise) { + val msg = "EAGER=${eagerTracker?.currentActivity?.localClassName} | DEFERRED=${deferredTracker.currentActivity?.localClassName}" + Log.i(TAG, "reportActivityState -> $msg") + promise.resolve(msg) + } +} diff --git a/sample/android/app/src/main/java/com/mparticlesample/DeferredInitPackage.kt b/sample/android/app/src/main/java/com/mparticlesample/DeferredInitPackage.kt new file mode 100644 index 0000000..6c2879d --- /dev/null +++ b/sample/android/app/src/main/java/com/mparticlesample/DeferredInitPackage.kt @@ -0,0 +1,14 @@ +package com.mparticlesample + +import com.facebook.react.ReactPackage +import com.facebook.react.bridge.NativeModule +import com.facebook.react.bridge.ReactApplicationContext +import com.facebook.react.uimanager.ViewManager + +class DeferredInitPackage : ReactPackage { + override fun createNativeModules(reactContext: ReactApplicationContext): List = + listOf(DeferredInitModule(reactContext)) + + override fun createViewManagers(reactContext: ReactApplicationContext): List> = + emptyList() +} diff --git a/sample/android/app/src/main/java/com/mparticlesample/MainApplication.kt b/sample/android/app/src/main/java/com/mparticlesample/MainApplication.kt index e2f29d0..d000ef5 100644 --- a/sample/android/app/src/main/java/com/mparticlesample/MainApplication.kt +++ b/sample/android/app/src/main/java/com/mparticlesample/MainApplication.kt @@ -19,6 +19,7 @@ class MainApplication : Application(), ReactApplication { packageList = PackageList(this).packages.apply { add(MParticlePackage()) + add(DeferredInitPackage()) }, ) } @@ -27,14 +28,24 @@ class MainApplication : Application(), ReactApplication { super.onCreate() loadReactNative(this) - val identityRequest = IdentityApiRequest.withEmptyUser() - - val options = MParticleOptions.builder(this) - .credentials("REPLACE_ME","REPLACE_ME") - .logLevel(MParticle.LogLevel.VERBOSE) - .identify(identityRequest.build()) - .build() - - MParticle.start(options) + if (DeferredInitModule.DEFERRED_INIT_EXAMPLE) { + // Deferred-init edge case (see DeferredInitModule for the full explanation). + // Register an eager activity tracker at process start -- before the first + // Activity is created -- so logcat can contrast it against the deferred path. + // MParticle.start() is intentionally NOT called here; DeferredInitModule + // starts it at first-frame paint instead (see index.js). + val eager = ActivityTracker("EAGER") + registerActivityLifecycleCallbacks(eager) + DeferredInitModule.eagerTracker = eager + } else { + // Standard, supported integration: start mParticle in Application.onCreate(). + val identityRequest = IdentityApiRequest.withEmptyUser() + val options = MParticleOptions.builder(this) + .credentials("REPLACE_ME", "REPLACE_ME") + .logLevel(MParticle.LogLevel.VERBOSE) + .identify(identityRequest.build()) + .build() + MParticle.start(options) + } } } diff --git a/sample/index.js b/sample/index.js index 476f61e..affbe10 100644 --- a/sample/index.js +++ b/sample/index.js @@ -19,6 +19,7 @@ import { View, TouchableOpacity, KeyboardAvoidingView, + NativeModules, } from 'react-native'; import MParticle from 'react-native-mparticle'; @@ -170,6 +171,23 @@ export default class MParticleSample extends Component { } componentDidMount() { + // Deferred-init edge-case example (Android). When DeferredInitModule.DEFERRED_INIT_EXAMPLE + // is enabled, mParticle is started from the native module at the moment the first frame is + // painted, instead of in MainApplication.onCreate(). requestAnimationFrame fires after the + // first frame is committed -- by which point MainActivity has already RESUMED, which is what + // triggers the Rokt overlay Activity-capture race. When the flag is off (default) this is a + // no-op. See DeferredInitModule.kt for the full explanation. + requestAnimationFrame(() => { + const {DeferredInit} = NativeModules; + if (DeferredInit) { + DeferredInit.startMParticle() + .then(r => console.log('Deferred MParticle.start ->', r)) + .catch(e => console.warn('Deferred start failed', e)); + } else { + console.warn('DeferredInit native module not available'); + } + }); + MParticle.getSession(session => this.setState({session})); if (eventManagerEmitter) { From f5e04bec4852be0ac4f14050e4d49045a9edf9a3 Mon Sep 17 00:00:00 2001 From: Nickolas Dimitrakas Date: Fri, 19 Jun 2026 13:52:32 -0400 Subject: [PATCH 2/4] fix(ci): apply ktlint formatting to sample deferred-init example Co-Authored-By: Claude Opus 4.8 --- .../com/mparticlesample/ActivityTracker.kt | 48 ++++---- .../com/mparticlesample/DeferredInitModule.kt | 116 +++++++++--------- .../mparticlesample/DeferredInitPackage.kt | 8 +- .../com/mparticlesample/MainApplication.kt | 66 +++++----- 4 files changed, 119 insertions(+), 119 deletions(-) diff --git a/sample/android/app/src/main/java/com/mparticlesample/ActivityTracker.kt b/sample/android/app/src/main/java/com/mparticlesample/ActivityTracker.kt index 50b794e..a35958c 100644 --- a/sample/android/app/src/main/java/com/mparticlesample/ActivityTracker.kt +++ b/sample/android/app/src/main/java/com/mparticlesample/ActivityTracker.kt @@ -21,28 +21,28 @@ import java.lang.ref.WeakReference */ class ActivityTracker(private val label: String) : Application.ActivityLifecycleCallbacks { - @Volatile - private var currentActivityRef: WeakReference? = null - - val currentActivity: Activity? - get() = currentActivityRef?.get() - - companion object { - const val TAG = "DeferredInitRepro" - } - - override fun onActivityResumed(activity: Activity) { - currentActivityRef = WeakReference(activity) - Log.i(TAG, "[$label] onActivityResumed -> captured ${activity.localClassName}") - } - - override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { - Log.i(TAG, "[$label] onActivityCreated ${activity.localClassName}") - } - - override fun onActivityStarted(activity: Activity) {} - override fun onActivityPaused(activity: Activity) {} - override fun onActivityStopped(activity: Activity) {} - override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} - override fun onActivityDestroyed(activity: Activity) {} + @Volatile + private var currentActivityRef: WeakReference? = null + + val currentActivity: Activity? + get() = currentActivityRef?.get() + + companion object { + const val TAG = "DeferredInitRepro" + } + + override fun onActivityResumed(activity: Activity) { + currentActivityRef = WeakReference(activity) + Log.i(TAG, "[$label] onActivityResumed -> captured ${activity.localClassName}") + } + + override fun onActivityCreated(activity: Activity, savedInstanceState: Bundle?) { + Log.i(TAG, "[$label] onActivityCreated ${activity.localClassName}") + } + + override fun onActivityStarted(activity: Activity) {} + override fun onActivityPaused(activity: Activity) {} + override fun onActivityStopped(activity: Activity) {} + override fun onActivitySaveInstanceState(activity: Activity, outState: Bundle) {} + override fun onActivityDestroyed(activity: Activity) {} } diff --git a/sample/android/app/src/main/java/com/mparticlesample/DeferredInitModule.kt b/sample/android/app/src/main/java/com/mparticlesample/DeferredInitModule.kt index cc4e5f8..566e7e0 100644 --- a/sample/android/app/src/main/java/com/mparticlesample/DeferredInitModule.kt +++ b/sample/android/app/src/main/java/com/mparticlesample/DeferredInitModule.kt @@ -39,70 +39,70 @@ import com.mparticle.identity.IdentityApiRequest * a pure TurboModule, which is what the race depends on.) */ class DeferredInitModule(private val reactContext: ReactApplicationContext) : - ReactContextBaseJavaModule(reactContext) { + ReactContextBaseJavaModule(reactContext) { - companion object { - const val TAG = "DeferredInitRepro" + companion object { + const val TAG = "DeferredInitRepro" - /** - * Master switch for the deferred-init edge case. Keep this `false` so the sample uses the - * standard eager init in MainApplication.onCreate(); set it to `true` to reproduce the - * late-init Activity-capture race described above. - */ - const val DEFERRED_INIT_EXAMPLE = false + /** + * Master switch for the deferred-init edge case. Keep this `false` so the sample uses the + * standard eager init in MainApplication.onCreate(); set it to `true` to reproduce the + * late-init Activity-capture race described above. + */ + const val DEFERRED_INIT_EXAMPLE = false - // Registered in Application.onCreate -- the eager path, for contrast. - var eagerTracker: ActivityTracker? = null - } + // Registered in Application.onCreate -- the eager path, for contrast. + var eagerTracker: ActivityTracker? = null + } + + // Registered at deferred-start time, mirroring Rokt.init()'s late registration. + private val deferredTracker = ActivityTracker("DEFERRED") + + override fun getName(): String = "DeferredInit" + + @ReactMethod + fun startMParticle(promise: Promise) { + if (!DEFERRED_INIT_EXAMPLE) { + // Standard mode: mParticle was already started in Application.onCreate(); nothing to do. + promise.resolve("eager-init (deferred example disabled)") + return + } + + Log.i(TAG, "startMParticle() called from JS (post first-frame). MParticle.getInstance()=${MParticle.getInstance()}") - // Registered at deferred-start time, mirroring Rokt.init()'s late registration. - private val deferredTracker = ActivityTracker("DEFERRED") + val app = reactContext.applicationContext as Application - override fun getName(): String = "DeferredInit" + // Mirror Rokt SDK: register the activity observer at init time, NOT at process start. + app.registerActivityLifecycleCallbacks(deferredTracker) + Log.i(TAG, "[DEFERRED] tracker registered. currentActivity right now = ${deferredTracker.currentActivity}") - @ReactMethod - fun startMParticle(promise: Promise) { - if (!DEFERRED_INIT_EXAMPLE) { - // Standard mode: mParticle was already started in Application.onCreate(); nothing to do. - promise.resolve("eager-init (deferred example disabled)") - return + val identityRequest = IdentityApiRequest.withEmptyUser() + val options = MParticleOptions.builder(app) + .credentials("REPLACE_ME", "REPLACE_ME") + .logLevel(MParticle.LogLevel.VERBOSE) + .identify(identityRequest.build()) + .build() + + MParticle.start(options) + Log.i(TAG, "MParticle.start() invoked from native module. getInstance()=${MParticle.getInstance()}") + + // Snapshot the captured activity 2s later: did the deferred observer ever + // see a resume? (Rokt's currentActivity cache works exactly this way.) + Handler(Looper.getMainLooper()).postDelayed({ + Log.i( + TAG, + "SNAPSHOT after 2s -> EAGER.currentActivity=${eagerTracker?.currentActivity?.localClassName} | " + + "DEFERRED.currentActivity=${deferredTracker.currentActivity?.localClassName}" + ) + }, 2000) + + promise.resolve("started") } - Log.i(TAG, "startMParticle() called from JS (post first-frame). MParticle.getInstance()=${MParticle.getInstance()}") - - val app = reactContext.applicationContext as Application - - // Mirror Rokt SDK: register the activity observer at init time, NOT at process start. - app.registerActivityLifecycleCallbacks(deferredTracker) - Log.i(TAG, "[DEFERRED] tracker registered. currentActivity right now = ${deferredTracker.currentActivity}") - - val identityRequest = IdentityApiRequest.withEmptyUser() - val options = MParticleOptions.builder(app) - .credentials("REPLACE_ME", "REPLACE_ME") - .logLevel(MParticle.LogLevel.VERBOSE) - .identify(identityRequest.build()) - .build() - - MParticle.start(options) - Log.i(TAG, "MParticle.start() invoked from native module. getInstance()=${MParticle.getInstance()}") - - // Snapshot the captured activity 2s later: did the deferred observer ever - // see a resume? (Rokt's currentActivity cache works exactly this way.) - Handler(Looper.getMainLooper()).postDelayed({ - Log.i( - TAG, - "SNAPSHOT after 2s -> EAGER.currentActivity=${eagerTracker?.currentActivity?.localClassName} | " + - "DEFERRED.currentActivity=${deferredTracker.currentActivity?.localClassName}" - ) - }, 2000) - - promise.resolve("started") - } - - @ReactMethod - fun reportActivityState(promise: Promise) { - val msg = "EAGER=${eagerTracker?.currentActivity?.localClassName} | DEFERRED=${deferredTracker.currentActivity?.localClassName}" - Log.i(TAG, "reportActivityState -> $msg") - promise.resolve(msg) - } + @ReactMethod + fun reportActivityState(promise: Promise) { + val msg = "EAGER=${eagerTracker?.currentActivity?.localClassName} | DEFERRED=${deferredTracker.currentActivity?.localClassName}" + Log.i(TAG, "reportActivityState -> $msg") + promise.resolve(msg) + } } diff --git a/sample/android/app/src/main/java/com/mparticlesample/DeferredInitPackage.kt b/sample/android/app/src/main/java/com/mparticlesample/DeferredInitPackage.kt index 6c2879d..a6bed18 100644 --- a/sample/android/app/src/main/java/com/mparticlesample/DeferredInitPackage.kt +++ b/sample/android/app/src/main/java/com/mparticlesample/DeferredInitPackage.kt @@ -6,9 +6,9 @@ import com.facebook.react.bridge.ReactApplicationContext import com.facebook.react.uimanager.ViewManager class DeferredInitPackage : ReactPackage { - override fun createNativeModules(reactContext: ReactApplicationContext): List = - listOf(DeferredInitModule(reactContext)) + override fun createNativeModules(reactContext: ReactApplicationContext): List = + listOf(DeferredInitModule(reactContext)) - override fun createViewManagers(reactContext: ReactApplicationContext): List> = - emptyList() + override fun createViewManagers(reactContext: ReactApplicationContext): List> = + emptyList() } diff --git a/sample/android/app/src/main/java/com/mparticlesample/MainApplication.kt b/sample/android/app/src/main/java/com/mparticlesample/MainApplication.kt index d000ef5..b8b6cf8 100644 --- a/sample/android/app/src/main/java/com/mparticlesample/MainApplication.kt +++ b/sample/android/app/src/main/java/com/mparticlesample/MainApplication.kt @@ -6,46 +6,46 @@ import com.facebook.react.ReactApplication import com.facebook.react.ReactHost import com.facebook.react.ReactNativeApplicationEntryPoint.loadReactNative import com.facebook.react.defaults.DefaultReactHost.getDefaultReactHost -import com.mparticle.react.MParticlePackage import com.mparticle.MParticle import com.mparticle.MParticleOptions import com.mparticle.identity.IdentityApiRequest +import com.mparticle.react.MParticlePackage class MainApplication : Application(), ReactApplication { - override val reactHost: ReactHost by lazy { - getDefaultReactHost( - context = applicationContext, - packageList = - PackageList(this).packages.apply { - add(MParticlePackage()) - add(DeferredInitPackage()) - }, - ) - } + override val reactHost: ReactHost by lazy { + getDefaultReactHost( + context = applicationContext, + packageList = + PackageList(this).packages.apply { + add(MParticlePackage()) + add(DeferredInitPackage()) + }, + ) + } - override fun onCreate() { - super.onCreate() - loadReactNative(this) + override fun onCreate() { + super.onCreate() + loadReactNative(this) - if (DeferredInitModule.DEFERRED_INIT_EXAMPLE) { - // Deferred-init edge case (see DeferredInitModule for the full explanation). - // Register an eager activity tracker at process start -- before the first - // Activity is created -- so logcat can contrast it against the deferred path. - // MParticle.start() is intentionally NOT called here; DeferredInitModule - // starts it at first-frame paint instead (see index.js). - val eager = ActivityTracker("EAGER") - registerActivityLifecycleCallbacks(eager) - DeferredInitModule.eagerTracker = eager - } else { - // Standard, supported integration: start mParticle in Application.onCreate(). - val identityRequest = IdentityApiRequest.withEmptyUser() - val options = MParticleOptions.builder(this) - .credentials("REPLACE_ME", "REPLACE_ME") - .logLevel(MParticle.LogLevel.VERBOSE) - .identify(identityRequest.build()) - .build() - MParticle.start(options) + if (DeferredInitModule.DEFERRED_INIT_EXAMPLE) { + // Deferred-init edge case (see DeferredInitModule for the full explanation). + // Register an eager activity tracker at process start -- before the first + // Activity is created -- so logcat can contrast it against the deferred path. + // MParticle.start() is intentionally NOT called here; DeferredInitModule + // starts it at first-frame paint instead (see index.js). + val eager = ActivityTracker("EAGER") + registerActivityLifecycleCallbacks(eager) + DeferredInitModule.eagerTracker = eager + } else { + // Standard, supported integration: start mParticle in Application.onCreate(). + val identityRequest = IdentityApiRequest.withEmptyUser() + val options = MParticleOptions.builder(this) + .credentials("REPLACE_ME", "REPLACE_ME") + .logLevel(MParticle.LogLevel.VERBOSE) + .identify(identityRequest.build()) + .build() + MParticle.start(options) + } } - } } From 52e10594517efdbdea5da4bf62a7287ca2b13e35 Mon Sep 17 00:00:00 2001 From: Nickolas Dimitrakas Date: Fri, 19 Jun 2026 14:02:44 -0400 Subject: [PATCH 3/4] fix(sample): gate deferred-init to Android and make startMParticle idempotent Addresses review feedback: - index.js: only run the first-frame DeferredInit call on Android, so iOS no longer logs a spurious 'module not available' warning every launch. - DeferredInitModule: short-circuit startMParticle() when MParticle is already started, so repeat JS calls (Fast Refresh / remounts) don't double-register lifecycle callbacks or re-start the SDK. Co-Authored-By: Claude Opus 4.8 --- .../com/mparticlesample/DeferredInitModule.kt | 7 ++++ sample/index.js | 36 ++++++++++--------- 2 files changed, 27 insertions(+), 16 deletions(-) diff --git a/sample/android/app/src/main/java/com/mparticlesample/DeferredInitModule.kt b/sample/android/app/src/main/java/com/mparticlesample/DeferredInitModule.kt index 566e7e0..4705800 100644 --- a/sample/android/app/src/main/java/com/mparticlesample/DeferredInitModule.kt +++ b/sample/android/app/src/main/java/com/mparticlesample/DeferredInitModule.kt @@ -68,6 +68,13 @@ class DeferredInitModule(private val reactContext: ReactApplicationContext) : return } + // Idempotent: JS may invoke this more than once (Fast Refresh, remounts). Once mParticle + // has started, skip re-registering the lifecycle observer and re-starting the SDK. + if (MParticle.getInstance() != null) { + promise.resolve("already started") + return + } + Log.i(TAG, "startMParticle() called from JS (post first-frame). MParticle.getInstance()=${MParticle.getInstance()}") val app = reactContext.applicationContext as Application diff --git a/sample/index.js b/sample/index.js index affbe10..5851514 100644 --- a/sample/index.js +++ b/sample/index.js @@ -171,22 +171,26 @@ export default class MParticleSample extends Component { } componentDidMount() { - // Deferred-init edge-case example (Android). When DeferredInitModule.DEFERRED_INIT_EXAMPLE - // is enabled, mParticle is started from the native module at the moment the first frame is - // painted, instead of in MainApplication.onCreate(). requestAnimationFrame fires after the - // first frame is committed -- by which point MainActivity has already RESUMED, which is what - // triggers the Rokt overlay Activity-capture race. When the flag is off (default) this is a - // no-op. See DeferredInitModule.kt for the full explanation. - requestAnimationFrame(() => { - const {DeferredInit} = NativeModules; - if (DeferredInit) { - DeferredInit.startMParticle() - .then(r => console.log('Deferred MParticle.start ->', r)) - .catch(e => console.warn('Deferred start failed', e)); - } else { - console.warn('DeferredInit native module not available'); - } - }); + // Deferred-init edge-case example (Android only). The DeferredInit native module exists only + // on Android; iOS resolves the presenter lazily and is unaffected, so we skip it there to keep + // iOS quiet. When DeferredInitModule.DEFERRED_INIT_EXAMPLE is enabled, mParticle is started + // from the native module at the moment the first frame is painted, instead of in + // MainApplication.onCreate(). requestAnimationFrame fires after the first frame is committed -- + // by which point MainActivity has already RESUMED, which is what triggers the Rokt overlay + // Activity-capture race. When the flag is off (default) this is a no-op on the native side. + // See DeferredInitModule.kt for the full explanation. + if (Platform.OS === 'android') { + requestAnimationFrame(() => { + const {DeferredInit} = NativeModules; + if (DeferredInit) { + DeferredInit.startMParticle() + .then(r => console.log('Deferred MParticle.start ->', r)) + .catch(e => console.warn('Deferred start failed', e)); + } else { + console.warn('DeferredInit native module not available'); + } + }); + } MParticle.getSession(session => this.setState({session})); From 5c643acc195f9a60101ef170d9bcc9fa85d19188 Mon Sep 17 00:00:00 2001 From: Nickolas Dimitrakas Date: Fri, 19 Jun 2026 14:10:40 -0400 Subject: [PATCH 4/4] docs(sample): note that pre-init JS calls no-op under deferred init Documents the expected secondary deferred-init hazard flagged in review: with DEFERRED_INIT_EXAMPLE on, mParticle JS calls made before first-frame start are no-ops until the SDK starts. Default (eager) behaviour is unchanged. Co-Authored-By: Claude Opus 4.8 --- sample/README.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/sample/README.md b/sample/README.md index 58dca06..383c7eb 100644 --- a/sample/README.md +++ b/sample/README.md @@ -180,6 +180,13 @@ The `EAGER` tracker (registered at process start) captures `MainActivity`; the ` tracker (registered when init runs at first frame) stays `null` until you background and reopen the app — demonstrating the race. See `DeferredInitModule.kt` for the full write-up. +> **Note:** When the flag is enabled, mParticle is not started until first-frame paint, so any +> mParticle JS calls made earlier (e.g. `Identity.login` in the component constructor, +> `getSession` in `componentDidMount`) run before the SDK is started and are no-ops until then. +> This is itself an inherent hazard of deferred initialisation and is expected in this example; +> gate such calls behind init completion in a real deferred-init integration. With the flag off +> (default) mParticle starts eagerly in `onCreate()`, so these calls behave normally. + ## Additional Resources - [mParticle Documentation](https://docs.mparticle.com/)