Summary
On React Native, a persisted native opt-in from a previous launch outranks the opt-out the JS client passes into native setup(). Native integrations that drain stored work during setup() — native crash reports ($exception), session replay, and now push opens — are therefore installed and fire for a user the JS client considers opted out. The JS layer re-asserts consent, but only after setup() has already returned.
Found by @dustinbyrne while reviewing #4921 and #4929. It is not introduced by either PR; both are affected by it.
Root cause
Both native SDKs let the persisted value overwrite the one the caller explicitly passed in.
iOS — PostHog/PostHogSDK.swift (in setup):
optOutLock.withLock {
let optOut = theStorage.getBool(forKey: .optOut)
config.optOut = optOut ?? config.optOut
}
getBool round-trips a stored false as Optional(false), not nil, so a persisted opt-in silently replaces a config.optOut = true the RN plugin just set. Everything gated below it inherits the falsified value — if !config.optOut { installIntegrations() }, and installsPushNotificationOpenIntegration (capturePushNotificationOpened && enableSwizzling && !optOut).
Android — posthog/src/main/java/com/posthog/PostHog.kt, isOptedOut():
(getPreferences().getValue(OPT_OUT, defaultValue = config.optOut) as? Boolean)?.let {
config.optOut = it
}
Same inversion, forced during setup().
Why JS can't currently correct it in time
packages/react-native/src/posthog-rn.ts already documents the inversion:
await OptionalReactNativePlugin.setup(String(sessionId), sdkOptions, pluginConfig)
// Native resolves its own persisted opt-out over the config value passed above, so an
// earlier optIn() keeps winning and sdkOptions.optOut alone can't opt a user back out.
// Only an explicit optOut()/optIn() overwrites it, so re-assert JS consent every setup.
await OptionalReactNativePlugin.setOptOut?.(this.optedOut)
The re-assert runs after setup() resolves — but the drains happen inside setup(). On Android the ordering is deterministic, not racy: captureColdStartPushOpenIfNeeded(config) runs before promise.resolve(null) in PosthogReactNativePluginModule.kt. optOut() does not drain the queue, so an event admitted in that window still flushes.
Reproduction
Config: { persistence: 'memory', defaultOptIn: false, errorTracking: { autocapture: { nativeCrashes: true } } }
- Launch 1,
defaultOptIn: true: call posthog.optIn(). Native persists opt-out = false to its own store (iOS: Application Support; Android: SharedPreferences "opt-out") — independent of the JS persistence setting.
- Launch 2,
defaultOptIn: false: with memory persistence JS has no opted_out key, so optedOut falls back to !defaultOptIn = true. JS passes optOut: true into native setup(). Native overwrites it with the persisted false and drains its stored work.
Memory persistence is sufficient but not necessary — the same divergence occurs with default persistence if the app ships a new version flipping defaultOptIn to false and the user never called optIn()/optOut() explicitly.
Scope
Not push-specific. A push-only patch would be a half fix:
$exception — PostHogErrorTrackingAutoCaptureIntegration.install() calls processPendingCrashReportIfNeeded synchronously, replaying the previous launch's crash report. Already on main; ordered before the push integration.
- Session replay —
_isEnableSessionReplay() checks only isDisabled, not optedOut, so recording starts natively.
- Push opens — the cold-start/prewarm drain.
Also worth noting: the iOS safety net discardPrewarmedNotificationResponseCapture() fires only when !installsPushNotificationOpenIntegration, so the falsified value disarms the very guard meant to cover the opted-out case.
Why tests miss it
PostHogPushNotificationSwizzlingTest.swift calls storage.reset() before setup, so the .optOut key is absent and optOut ?? config.optOut falls through to the config value. The disk-wins branch is never exercised.
Suggested fix
Make an explicitly-passed optOut: true win over the persisted value, in the native SDKs — the caller stating consent now should outrank what a previous launch stored. Alternatively, have the RN plugin apply consent between setup() and the drains. The first is the real fix; the second only narrows the window.
Wants a two-launch regression test asserting that neither $push_notification_opened nor $exception reaches the native queue or disk.
Summary
On React Native, a persisted native opt-in from a previous launch outranks the opt-out the JS client passes into native
setup(). Native integrations that drain stored work duringsetup()— native crash reports ($exception), session replay, and now push opens — are therefore installed and fire for a user the JS client considers opted out. The JS layer re-asserts consent, but only aftersetup()has already returned.Found by @dustinbyrne while reviewing #4921 and #4929. It is not introduced by either PR; both are affected by it.
Root cause
Both native SDKs let the persisted value overwrite the one the caller explicitly passed in.
iOS —
PostHog/PostHogSDK.swift(insetup):getBoolround-trips a storedfalseasOptional(false), notnil, so a persisted opt-in silently replaces aconfig.optOut = truethe RN plugin just set. Everything gated below it inherits the falsified value —if !config.optOut { installIntegrations() }, andinstallsPushNotificationOpenIntegration(capturePushNotificationOpened && enableSwizzling && !optOut).Android —
posthog/src/main/java/com/posthog/PostHog.kt,isOptedOut():Same inversion, forced during
setup().Why JS can't currently correct it in time
packages/react-native/src/posthog-rn.tsalready documents the inversion:The re-assert runs after
setup()resolves — but the drains happen insidesetup(). On Android the ordering is deterministic, not racy:captureColdStartPushOpenIfNeeded(config)runs beforepromise.resolve(null)inPosthogReactNativePluginModule.kt.optOut()does not drain the queue, so an event admitted in that window still flushes.Reproduction
Config:
{ persistence: 'memory', defaultOptIn: false, errorTracking: { autocapture: { nativeCrashes: true } } }defaultOptIn: true: callposthog.optIn(). Native persists opt-out =falseto its own store (iOS: Application Support; Android: SharedPreferences"opt-out") — independent of the JSpersistencesetting.defaultOptIn: false: with memory persistence JS has noopted_outkey, sooptedOutfalls back to!defaultOptIn=true. JS passesoptOut: trueinto nativesetup(). Native overwrites it with the persistedfalseand drains its stored work.Memory persistence is sufficient but not necessary — the same divergence occurs with default persistence if the app ships a new version flipping
defaultOptIntofalseand the user never calledoptIn()/optOut()explicitly.Scope
Not push-specific. A push-only patch would be a half fix:
$exception—PostHogErrorTrackingAutoCaptureIntegration.install()callsprocessPendingCrashReportIfNeededsynchronously, replaying the previous launch's crash report. Already onmain; ordered before the push integration._isEnableSessionReplay()checks onlyisDisabled, notoptedOut, so recording starts natively.Also worth noting: the iOS safety net
discardPrewarmedNotificationResponseCapture()fires only when!installsPushNotificationOpenIntegration, so the falsified value disarms the very guard meant to cover the opted-out case.Why tests miss it
PostHogPushNotificationSwizzlingTest.swiftcallsstorage.reset()before setup, so the.optOutkey is absent andoptOut ?? config.optOutfalls through to the config value. The disk-wins branch is never exercised.Suggested fix
Make an explicitly-passed
optOut: truewin over the persisted value, in the native SDKs — the caller stating consent now should outrank what a previous launch stored. Alternatively, have the RN plugin apply consent betweensetup()and the drains. The first is the real fix; the second only narrows the window.Wants a two-launch regression test asserting that neither
$push_notification_openednor$exceptionreaches the native queue or disk.