@posthog/react-native-plugin 2.9.1 → 2.9.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -221,7 +221,7 @@ class PosthogReactNativePluginModule(
221
221
 
222
222
  setIdentify(config.cachePreferences, distinctId, anonymousId)
223
223
 
224
- captureColdStartPushOpenIfNeeded(config)
224
+ captureColdStartPushOpenIfNeeded(config, jsOptedOut = theOptOut)
225
225
  } catch (e: Throwable) {
226
226
  logError(method, e)
227
227
  } finally {
@@ -488,7 +488,15 @@ class PosthogReactNativePluginModule(
488
488
  // cold-start tray tap it exists for is the one creation it can never observe here. Read
489
489
  // the launch intent directly, then strip the marker so the integration (or a re-run)
490
490
  // can't capture the same tap again from this intent object.
491
- private fun captureColdStartPushOpenIfNeeded(config: PostHogAndroidConfig) {
491
+ //
492
+ // jsOptedOut is the consent JS passed into this setup(). The native SDK lets the opt-out it
493
+ // persisted itself win over that value, so after an earlier launch opted in, config.optOut no
494
+ // longer says what JS said and native would capture. A tap JS considers denied is consumed
495
+ // here without being captured, so a later opt-in cannot resurrect it either.
496
+ private fun captureColdStartPushOpenIfNeeded(
497
+ config: PostHogAndroidConfig,
498
+ jsOptedOut: Boolean,
499
+ ) {
492
500
  if (!config.capturePushNotificationOpened) {
493
501
  return
494
502
  }
@@ -500,6 +508,10 @@ class PosthogReactNativePluginModule(
500
508
  }
501
509
  try {
502
510
  intent.getStringExtra(GOOGLE_MESSAGE_ID) ?: return
511
+ if (jsOptedOut) {
512
+ intent.removeExtra(GOOGLE_MESSAGE_ID)
513
+ return
514
+ }
503
515
  // Unmarshalling extras throws BadParcelableException on a Parcelable class this
504
516
  // classloader lacks; read before stripping the marker so a failed read leaves the
505
517
  // intent as the native integration expects it.
@@ -75,3 +75,25 @@ RCT_EXTERN_METHOD(providePushIdentityToken:(NSString)requestId
75
75
  }
76
76
 
77
77
  @end
78
+
79
+ #if TARGET_OS_IOS
80
+ #import <UIKit/UIKit.h>
81
+
82
+ // Implemented in PosthogReactNativePlugin.swift.
83
+ @interface PosthogReactNativePlugin (PushNotificationOpenPrewarm)
84
+ + (void)prewarmPushNotificationOpenCapture;
85
+ @end
86
+
87
+ // A notification tap that cold-launches the app is delivered right after launch, long before JS
88
+ // reaches setup(), and native modules are created lazily, so no module code runs in time. This
89
+ // runs at image load, like the RCT_EXTERN_MODULE registration above, so the host app needs no code.
90
+ __attribute__((constructor)) static void PosthogReactNativePluginObserveLaunch(void)
91
+ {
92
+ [[NSNotificationCenter defaultCenter] addObserverForName:UIApplicationDidFinishLaunchingNotification
93
+ object:nil
94
+ queue:nil
95
+ usingBlock:^(__unused NSNotification *notification) {
96
+ [PosthogReactNativePlugin prewarmPushNotificationOpenCapture];
97
+ }];
98
+ }
99
+ #endif
@@ -47,6 +47,50 @@ private func isReactNativeFatalJsError(_ event: PostHogEvent) -> Bool {
47
47
  }
48
48
  }
49
49
 
50
+ /// Carries the distinct id and consent JS holds into `beforeSend` for the length of
51
+ /// `PostHogSDK.setup(_:)`.
52
+ ///
53
+ /// `setup()` installs the push-open integration, which replays a tap that cold-launched the app
54
+ /// synchronously — before `setIdentify` can mirror that id into native storage, so the replayed
55
+ /// event would carry whatever identity the previous launch left behind, and before the JS layer
56
+ /// re-asserts its consent: the native SDK prefers the opt-out it persisted itself, so after an
57
+ /// earlier launch opted in, `config.optOut` no longer says what JS said and the replay is captured
58
+ /// for a user JS considers opted out. posthog-ios offers no seam for either earlier (its storage
59
+ /// manager is created inside `setup()`, with an internal initializer, and disabling the integration
60
+ /// to delay the replay discards the held tap instead of deferring it) — so both are applied on the
61
+ /// way out instead: the id is stamped, and a tap JS denied is dropped, consumed so a later opt-in
62
+ /// cannot resurrect it. Cleared once `setup()` returns, after which every event resolves its
63
+ /// identity and consent from native state as before.
64
+ private final class SetupWindow {
65
+ private let lock = NSLock()
66
+ private var distinctId: String?
67
+ private var jsOptedOut: Bool
68
+
69
+ init(distinctId: String, jsOptedOut: Bool) {
70
+ self.distinctId = distinctId.isEmpty ? nil : distinctId
71
+ self.jsOptedOut = jsOptedOut
72
+ }
73
+
74
+ var pendingDistinctId: String? {
75
+ lock.lock()
76
+ defer { lock.unlock() }
77
+ return distinctId
78
+ }
79
+
80
+ var dropsPushOpens: Bool {
81
+ lock.lock()
82
+ defer { lock.unlock() }
83
+ return jsOptedOut
84
+ }
85
+
86
+ func settled() {
87
+ lock.lock()
88
+ defer { lock.unlock() }
89
+ distinctId = nil
90
+ jsOptedOut = false
91
+ }
92
+ }
93
+
50
94
  // A nil identity token sends the request unauthenticated, which a project requiring
51
95
  // identity verification rejects server-side. Log the reason so that failure is greppable
52
96
  // and distinct from a host that deliberately returned nil.
@@ -69,6 +113,23 @@ public class PosthogReactNativePlugin: RCTEventEmitter {
69
113
  private var hasPushListeners = false
70
114
  private var pushIdentityCompletions: [String: (String?) -> Void] = [:]
71
115
 
116
+ #if os(iOS)
117
+ /// The one opt-out that reaches both halves of push-open capture, named and read exactly as in
118
+ /// posthog-flutter: `false` skips the launch hook below *and* forces the native config flag off
119
+ /// in setup(), so the JS flag can't re-enable what the plist turned off.
120
+ private static var plistCapturePushNotificationOpened: Bool {
121
+ Bundle.main.object(forInfoDictionaryKey: "com.posthog.posthog.CAPTURE_PUSH_NOTIFICATION_OPENED") as? Bool ?? true
122
+ }
123
+
124
+ /// Runs at launch, before JS config exists, so the Info.plist key is the only way to skip it.
125
+ @objc static func prewarmPushNotificationOpenCapture() {
126
+ guard plistCapturePushNotificationOpened else { return }
127
+ if #available(iOS 14.0, *) {
128
+ PostHogSDK.prewarmPushNotificationOpenCapture()
129
+ }
130
+ }
131
+ #endif
132
+
72
133
  public override func supportedEvents() -> [String]! {
73
134
  [PosthogReactNativePlugin.pushIdentityEvent]
74
135
  }
@@ -183,10 +244,31 @@ public class PosthogReactNativePlugin: RCTEventEmitter {
183
244
  }
184
245
  #endif
185
246
 
247
+ let distinctId = sdkOptions["distinctId"] as? String ?? ""
248
+ let anonymousId = sdkOptions["anonymousId"] as? String ?? ""
249
+ let jsOptedOut = sdkOptions["optOut"] as? Bool ?? false
250
+ let setupWindow = SetupWindow(distinctId: distinctId, jsOptedOut: jsOptedOut)
251
+ // Every exit below is past the point where native storage carries the ids, or past a
252
+ // failure that left the SDK disabled, so no path can leave the window armed.
253
+ defer { setupWindow.settled() }
254
+
186
255
  // React Native rethrows fatal JS errors natively (RCTFatalException / ExceptionsManager).
187
256
  // The JS layer already captured them, so drop the native duplicate.
188
257
  config.setBeforeSend { event in
189
- isReactNativeFatalJsError(event) ? nil : event
258
+ if isReactNativeFatalJsError(event) {
259
+ return nil
260
+ }
261
+ // Only the replayed tap. `setup()` also replays the previous launch's crash report,
262
+ // and that `$exception` carries the distinct id recorded at crash time, which stands.
263
+ if event.event == "$push_notification_opened" {
264
+ if setupWindow.dropsPushOpens {
265
+ return nil
266
+ }
267
+ if let distinctId = setupWindow.pendingDistinctId {
268
+ event.distinctId = distinctId
269
+ }
270
+ }
271
+ return event
190
272
  }
191
273
 
192
274
  // Surveys and session replay are iOS-only in posthog-ios, so the APIs below
@@ -238,15 +320,12 @@ public class PosthogReactNativePlugin: RCTEventEmitter {
238
320
  }
239
321
  #endif
240
322
 
241
- let distinctId = sdkOptions["distinctId"] as? String ?? ""
242
- let anonymousId = sdkOptions["anonymousId"] as? String ?? ""
243
-
244
323
  let sdkVersion = sdkOptions["sdkVersion"] as? String ?? ""
245
324
 
246
325
  let flushAt = sdkOptions["flushAt"] as? Int ?? 20
247
326
  config.flushAt = flushAt
248
327
 
249
- config.optOut = sdkOptions["optOut"] as? Bool ?? false
328
+ config.optOut = jsOptedOut
250
329
  // JS owns flags; it tells us when the native preload would be a duplicate. posthog-ios
251
330
  // has no remoteConfig switch to mirror — it deprecated the option and always loads.
252
331
  config.preloadFeatureFlags = sdkOptions["preloadFeatureFlags"] as? Bool ?? true
@@ -271,6 +350,13 @@ public class PosthogReactNativePlugin: RCTEventEmitter {
271
350
  if let capturePushOpened = pushConfig["capturePushNotificationOpened"] as? Bool {
272
351
  config.capturePushNotificationOpened = capturePushOpened
273
352
  }
353
+ #if os(iOS)
354
+ // Only ever turns capture off: the plist is the app's build-time kill switch, so it wins
355
+ // over the JS flag, while a plist `true` leaves a JS opt-out alone.
356
+ if !PosthogReactNativePlugin.plistCapturePushNotificationOpened {
357
+ config.capturePushNotificationOpened = false
358
+ }
359
+ #endif
274
360
 
275
361
  // Installed only when JS asked for it: an uninvited bridging provider would change
276
362
  // how the native SDK handles a 401 on the subscription call.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@posthog/react-native-plugin",
3
- "version": "2.9.1",
3
+ "version": "2.9.3",
4
4
  "description": "PostHog React Native plugin for iOS and Android integrations",
5
5
  "source": "./src/index.tsx",
6
6
  "main": "./lib/commonjs/index.js",