Skip to content

React Native: persisted native opt-in overrides JS opt-out, so setup-time native captures bypass consent #4965

Description

@turnipdabeets

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.

iOSPostHog/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).

Androidposthog/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 } } }

  1. 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.
  2. 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:

  • $exceptionPostHogErrorTrackingAutoCaptureIntegration.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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions