Skip to content

feat(react-native): capture Android push opens when the process was killed - #4929

Merged
turnipdabeets merged 9 commits into
mainfrom
fix/rn-android-new-intent-push-open
Sep 15, 2026
Merged

turnipdabeets merged 9 commits into
mainfrom
fix/rn-android-new-intent-push-open

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Sep 12, 2026

Copy link
Copy Markdown
Contributor

Problem

On Android, a push notification tap is silently lost when the app's process was killed but its task is still in recents (the state you get from a low-memory kill, or adb shell am kill <pkg>). Nothing is captured, and the tap is invisible to every library in the process — not just ours.

This is a React Native defect. The tap arrives at Activity.onNewIntent while the React context is still starting, and three things line up:

  • ReactHostImpl.onNewIntent (ReactAndroid/.../runtime/ReactHostImpl.java:800-821) drops the intent when getCurrentReactContext() == null — it only raises a soft exception.
  • ReactDelegate.onNewIntent returns true unconditionally on the new architecture, so ReactActivity never calls super.onNewIntent(intent) — which also means the AndroidX OnNewIntentListener chain never runs.
  • Nothing calls setIntent(intent), so getIntent() keeps returning the original launch intent forever.

Consequences in a plain Expo/RN app:

  • our ActivityEventListener.onNewIntent never fires (no React context yet, and the delegate swallowed it),
  • activity.intent still holds the original launch intent when the bridge reaches setup(), so the cold-start capture path finds nothing,
  • @react-native-firebase/messaging's getInitialNotification() returns null for the same reason, and deep links are lost too.

Related: #4858, #4919, PostHog/posthog-android#783.

Changes

The host-side fix is one line — call setIntent(intent) before super.onNewIntent(intent) in MainActivity. That makes getIntent() correct for the rest of the process, so our cold-start path picks the tap up at setup(), and it repairs Firebase's getInitialNotification() and deep links at the same time.

This PR injects that override through the existing Expo config plugin, so Expo and prebuild apps need no native code:

  • New withDangerousMod('android', …) in packages/react-native/src/tooling/expoconfig.ts, alongside the existing withAppBuildGradle / withXcodeProject mods. It finds MainActivity.{kt,java} under android/app/src/main/{java,kotlin} itself, and warns and writes nothing when there is none — Expo's own mainActivity base mod only globs src/main/java and asserts, which would turn a project with sources in src/main/kotlin (or no MainActivity at all) into a hard expo prebuild failure whose message never mentions PostHog. Dangerous mods run before the standard android chain, so another plugin's withMainActivity still reads and re-writes our edit. Kotlin and Java MainActivity both supported (the parameter is spelled android.content.Intent so no import edit is needed).
  • A one-line console notice on the prebuild that actually writes the override (not on a no-op re-run), naming the file and the opt-out — the same convention disableSandboxing follows, since this edits the app's own source.
  • Opt-out, matching disableSandboxing: on by default, { patchMainActivityNewIntent: false } skips it — and also removes a block a previous prebuild wrote, matching how dotenvFile / releaseMode already behave.
  • Idempotent: the block is delimited by @generated begin/end posthog-new-intent markers and rewritten in place, so repeated prebuilds never stack copies.
  • A MainActivity that already overrides onNewIntent is left alone with a warning telling the developer to add setIntent(intent) as the first statement of their own override. Two overrides would not compile, and we must never reorder someone's setIntent after their super call.
  • If the file does not look like a MainActivity we recognize (no class body found between the class name and a {, or unbalanced braces), the plugin warns and writes nothing rather than guessing. The class-body scan ignores string and character literals, raw strings, Kotlin ${...} templates and comments, so a "}" field cannot end the class early and hide a real onNewIntent override from the check; an unterminated literal is treated as unrecognizable source and skipped.
  • @posthog/react-native-plugin (Android): a setup-time consent guard. This override feeds a tap that arrived while React was starting into the plugin's existing cold-start drain in setup(). That drain runs before the JS layer can re-assert its consent, and the native SDK lets the opt-out it persisted itself outrank the optOut JS passes in (React Native: persisted native opt-in overrides JS opt-out, so setup-time native captures bypass consent #4965), so after an earlier launch opted in, a tap the JS client considers denied was captured. The drain now takes the optOut JS passed into this setup(): when it is true, the tap's marker is stripped without capturing, so a later opt-in or repeated drain cannot resurrect it. Scoped to that one drain; later taps still go through native consent as before. The broader inversion ($exception replay, session replay) stays with React Native: persisted native opt-in overrides JS opt-out, so setup-time native captures bypass consent #4965.

Bare React Native apps that never run expo prebuild still need the override by hand; the docs snippet for that is going up on posthog.com separately.

Verification

Bare RN example (examples/example-rn-native-plugin, @react-native-firebase/messaging, new architecture, launchMode="singleTask"), real FCM tray notification posted over com.google.android.c2dm.intent.RECEIVE and tapped through the notification shade with real UI input, $push_notification_opened counted from /batch payloads against a local mock. No expo prebuild — the example is bare RN, so the plugin's generated block was applied to its MainActivity.kt verbatim.

Scenario Before After
Warm tap (process alive) 1 1
am kill, task still in recents 0 1
Ordinary cold start (task swiped away, then killed) 1 1

Firebase's getInitialNotification() went from null to the message id in the am kill case, confirming the same root cause and that the fix repairs it too.

launchMode is not the cause: the same am kill scenario against the RN example switched to android:launchMode="singleTop" (no source patch) still captured 0 events. Flutter surviving this is not down to its singleTop launch mode, so there is no cheaper manifest-only mitigation.

Consent guard (review follow-up)

Same bare RN example on a Pixel 9 emulator (API 37), driving the plugin's setup() directly with the optOut a JS client would pass, flushAt: 1, native crash capture on, $push_notification_opened counted from /batch bodies at a local mock and from cache/posthog-disk-queue in the app's data dir. "Retained-task tap" is am kill with the task left in recents, then the tray tap's intent. Control = this branch without the guard commit; guard = with it.

Scenario Control Guard
Consent allowed, retained-task tap 1 1
Two launches: launch 1 opts in (native persists opt-out=false), launch 2 passes optOut: true, retained-task tap 1 (mock and queue) 0 (mock and queue)
Fresh storage, optOut: true, retained-task tap 0 0
After the denied launch: launch 3 passes optOut: false, then a warm tap 1, the new tap only; the denied one never reappears

The plugin transform itself is covered by unit tests (fresh Kotlin, fresh Java, already-patched no-op, pre-existing onNewIntent override, a comment that merely mentions onNewIntent, a CRLF file, a second class in the same file, opt-out removal, unrecognizable file, a "}" literal before an existing override in Kotlin and Java, lone braces in literals/raw strings/templates/comments in both languages, an unterminated string) plus end-to-end compileModsAsync tests through Expo's real mod compiler covering the standard src/main/java layout, src/main/kotlin, a MainActivity.java, and a project with no MainActivity (warns, does not throw, writes nothing). The generated block was also run through the mod compiler against examples/example-expo-57's real prebuilt MainActivity.kt and compiled by Gradle.

Release info Sub-libraries affected

Libraries affected

  • All of them
  • posthog-js (web)
  • posthog-js-lite (web lite)
  • posthog-node
  • posthog-react-native
  • @posthog/react-native-plugin
  • @posthog/react
  • @posthog/ai
  • @posthog/convex
  • @posthog/next
  • @posthog/nextjs-config
  • @posthog/nuxt
  • @posthog/openfeature-node-provider
  • @posthog/openfeature-web-provider
  • @posthog/rollup-plugin
  • @posthog/webpack-plugin
  • @posthog/types
  • @posthog/browser-common

Checklist

  • Tests for new code
  • Accounted for the impact of any changes across different platforms
  • Accounted for backwards compatibility of any changes (no breaking changes!)
  • Took care not to unnecessarily increase the bundle size

If releasing new changes

  • Ran pnpm changeset to generate a changeset file

🤖 Agent context

Autonomy: Human-driven (agent-assisted)

Claude Code (Opus 5) implemented this from an investigation that had already isolated the React Native code path. Tools: Read/Edit/Bash, Android emulator on a dedicated AVD instance, vitest.

Decisions along the way:

  • Config plugin over a native module change. The fix has to run in the host Activity before any React code exists, so no amount of work inside PosthogReactNativePluginModule can see the intent. Patching MainActivity is the only place it can go.
  • Opt-out rather than opt-in, following disableSandboxing: under expo prebuild MainActivity is a generated file, the override is what Android's own docs prescribe, and an app that already overrides onNewIntent is skipped rather than patched.
  • Rejected: launchMode="singleTop" as a cheaper manifest-only mitigation. Measured on the emulator, it does not help (still 0 events), so the source patch is the fix.
  • Rejected: adding an import android.content.Intent. Fully qualifying the parameter type keeps the transform to a single insertion point and cannot collide with an existing import.
  • Rejected: registering Expo's mainActivity base mod. It resolves the file with a glob over src/main/java only and asserts, so a project with sources in src/main/kotlin, a hand-maintained android/ folder, or no MainActivity at all would get a hard expo prebuild failure that never names PostHog. expo-splash-screen registers the same mod in the default template, but it is removable, so relying on it is conditional, not universal. A withDangerousMod that does its own lookup and warns when it finds nothing has neither problem, and picks up src/main/kotlin besides.

Related PRs

One push-open capture effort across the mobile SDKs: count every notification tap exactly once, and stop losing taps the SDK starts too late to see.

PR What it does Blocked by
PostHog/posthog-android#783 Core: capture each PostHog push open once, whichever path reports it (ships as 3.65.0)
PostHog/posthog-ios#828 Same rule on iOS, so both platforms behave identically (ships as 3.75.0)
#4921 React Native on iOS: capture a tap that launches the app
#4929 (this PR) React Native on Android: capture a tap lost when the process was killed but the task stayed in recents
PostHog/posthog-flutter#579 Flutter: replay a tap that arrives before the SDK is set up, instead of relying on firebase_messaging
#4919 React Native plugin: take the core dedupe, drop the plugin-level copy posthog-android 3.65.0
PostHog/posthog-flutter#578 Flutter plugin: take the core dedupe, drop the plugin-level copy posthog-android 3.65.0
PostHog/posthog.com#20114 Docs corrections that are wrong today, independent of any release
PostHog/posthog.com#20102 Docs for the release-dependent behavior changes the SDK releases above

Merge order: posthog-android#783 and posthog-ios#828 first, then their releases. #4921, #4929 and #579 are independent and can go any time. #4919 and #578 go green once posthog-android 3.65.0 is published. Docs: #20114 can go now; #20102 last, after the releases.

Earlier work this builds on: PostHog/posthog-android#753, PostHog/posthog-ios#792, #4858, PostHog/posthog-flutter#556, PostHog/posthog-flutter#557, PostHog/posthog.com#19905.

…lled

Android delivers a notification tap through Activity.onNewIntent while React
Native's context is still starting. ReactHostImpl.onNewIntent drops the intent
when getCurrentReactContext() is null, ReactDelegate.onNewIntent still reports
it handled so ReactActivity never calls super.onNewIntent, and nothing calls
setIntent. The tap is then invisible to every library in the process.

Inject a MainActivity.onNewIntent override through the Expo config plugin that
calls setIntent(intent) before delegating, so getIntent() is correct for the
rest of the process.
Expo's mainActivity base mod globs android/app/src/main/java only and asserts,
so registering it turned sources under src/main/kotlin - or no MainActivity at
all - into a hard expo prebuild failure whose message never mentions PostHog.
Do the lookup in a dangerous mod over both java and kotlin source roots and
warn-and-skip when nothing matches.

Also from review: match a CRLF managed block so the opt-out still removes it,
require an onNewIntent declaration rather than the bare token before backing
off, and announce the write the way disableSandboxing does.
@greptile-apps

greptile-apps Bot commented Sep 12, 2026

Copy link
Copy Markdown
Contributor
Prompt To Fix All With AI
### Issue 1
packages/react-native/src/tooling/expoconfig.ts:356-359
**Wrong activity may be patched**

When multiple packages under these source roots contain a `MainActivity`, this traversal patches the first filesystem match instead of the manifest-declared launcher. The real activity remains unpatched, so killed-process notification taps are still lost, while an unrelated source file is modified. Resolve the launcher class from the Android manifest and patch its corresponding source file.

### Issue 2
packages/react-native/src/tooling/expoconfig.ts:316
**Helper overrides disable patching**

The existing-override check scans the entire file, so an `onNewIntent` method in a helper or second class is mistaken for an override on `MainActivity`. The plugin then skips injection, leaving the launcher vulnerable to lost notification taps. Restrict this check to direct methods in the identified `MainActivity` body.

### Issue 3
packages/react-native/src/tooling/expoconfig.ts:297
**Literal braces prevent patching**

`matchingBraceIndex` counts braces inside comments and string literals as structural braces. A valid `MainActivity` containing a lone brace in a comment or string can therefore be treated as unbalanced, causing the plugin to skip the override and leave killed-process notification taps uncaptured. Parse or scan the class body while ignoring comments and literals.

### Issue 4
.changeset/rn-android-main-activity-new-intent.md:5
**Changeset includes implementation details**

The repository requires changesets to contain only a short, user-facing description of the fix or feature. This entry instead describes the config-plugin injection and its opt-out property. Reduce it to the user-visible Android push-open fix; this repository requirement must be satisfied before merging.

```suggestion
Fix `$push_notification_opened` not being captured on Android when the app's process was killed but its task stayed in recents.
```

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Reviews (1): Last reviewed commit: "fix(react-native): find MainActivity our..." | Re-trigger Greptile

Comment thread packages/react-native/src/tooling/expoconfig.ts
Comment thread packages/react-native/src/tooling/expoconfig.ts Outdated
Comment thread packages/react-native/src/tooling/expoconfig.ts Outdated
Comment thread .changeset/rn-android-main-activity-new-intent.md Outdated

@dustinbyrne dustinbyrne left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consent before setup-time capture

Consider applying the current JS consent state before setup-time tap capture. The recovered Intent can reach native setup while persisted native opt-in disagrees with the new JS client's opt-out. The retained-task notification path needs a two-launch test with memory persistence, defaultOptIn: false, and nativeCrashes: true.

Launcher selection

Consider resolving the launcher from the manifest rather than selecting the first MainActivity basename. An auxiliary Java MainActivity can currently win over the actual Kotlin launcher, leaving the intended activity unpatched.

Override detection

Consider limiting existing-override detection to the target class. An unrelated helper class's onNewIntent declaration currently suppresses the patch.

Brace matching

Consider making brace matching aware of strings and comments. A valid declaration such as private val delimiter = "{" currently makes the scan report unbalanced source and skip the patch.

The check scanned the whole file, so an onNewIntent on a helper class in the
same source file made the plugin skip the override and leave killed-process
notification taps uncaptured. Resolve MainActivity's body first and look for
the existing override only inside it.

Trim the changeset to the user-facing line per RELEASING.md.
@turnipdabeets

Copy link
Copy Markdown
Contributor Author

Thanks @dustinbyrne — all four addressed.

Consent before setup-time capture — real, and filed as #4965 rather than patched here. The scenario is reachable: PostHog.isOptedOut() lets the persisted SharedPreferences value overwrite the config.optOut the plugin just set, and the ordering is deterministic rather than racy — captureColdStartPushOpenIfNeeded(config) runs before promise.resolve(null) in PosthogReactNativePluginModule.kt, so the JS await setup(...) hasn't returned yet and the setOptOut re-assert on the next line can't have run. optOut() doesn't drain the queue, so the event still flushes.

It isn't introduced by this PR, though: the diff touches no runtime code at all — it's expoconfig.ts plus tests. That same window already admits $exception and session replay on main today, and a plain cold-start tap (no retained task) already takes the identical path, so your two-launch test would fail on main without this PR applied. This PR adds one new input to an already-broken guard; it can't fix it from expoconfig.ts. Full trace for both platforms is in #4965.

Launcher selection — leaving as is. Expo's own getProjectFilePath, which withMainActivity resolves through, globs android/app/src/main/java/**/MainActivity.@(java|kt) and takes [0] with no manifest lookup — so first-match is what every config plugin in the ecosystem already does, and ours additionally covers src/main/kotlin, which Expo's misses. android/ is generated by prebuild from a template with exactly one MainActivity; a second one means the app hand-added it, and reading the manifest to choose between them is a bigger surface than the case it serves.

Override detection — good catch, fixed in 8649e6b. We resolve MainActivity's body span first and test for the existing override only inside it, so a helper class's onNewIntent no longer suppresses the patch. Covered by a test that fails against the previous whole-file check.

Brace matching — leaving as is. private val delimiter = "{" does make the scan report unbalanced, but the consequence is that we skip and warn, and the warning names the fix — the build still succeeds. Making the scanner literal-aware means handling Kotlin raw strings and ${} templates plus Java escapes and char literals, in a file Expo generates from a template containing none of them; that's more new failure surface than the case it removes. It's called out as a known limit in the comment above matchingBraceIndex.

@turnipdabeets
turnipdabeets requested review from a team and dustinbyrne September 14, 2026 20:34

@dustinbyrne dustinbyrne left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Re-reviewed 8649e6b5aadc0756f263a23a9b3d2e5b3632b5fe. I’m withdrawing my earlier manifest-selection request; the helper-class override detection is addressed. Two issues remain:

Consent on newly recovered taps

Consider gating the setup-time intent drain on the original incoming JS consent before enabling this new recovery path. With JS memory persistence, defaultOptIn: false, native crash capture enabled, and native opt-in retained from a previous launch, the generated override preserves a retained-task notification arriving before React is ready. Native setup restores its earlier consent and the bridge drains the intent before resolving setup; JS reasserts denial afterward.

The ordering defect is older, but base loses this particular early input while head admits it. #4965 tracks the broader issue without mitigating the added path. A narrow guard needs the corresponding plugin fix/release. A denied candidate should not reappear after later opt-in/repeated drain, while a genuinely new allowed tap should still capture. Consider a real two-launch retained-task regression that asserts no matching native queue event, with allowed-consent and fresh-storage controls.

A closing-brace literal can duplicate an existing override

The material brace case is "}" before an existing onNewIntent, rather than the opening-brace case that safely skips the file. The scanner treats that literal as the class end, so the scoped check misses the real override and inserts a duplicate method. Valid customized Kotlin/Java source becomes uncompilable.

Consider ignoring literals/comments in the boundary scan or conservatively refusing ambiguous source. Kotlin and Java fixtures with a "}" field before an existing override should preserve the source and compile; keep the stock-host and later-sibling cases as controls.

These are source-validated findings; I did not execute the proposed regressions or observe a device upload.

turnipdabeets and others added 2 commits September 14, 2026 21:28
…ivity

A "}" string before an existing onNewIntent ended the class early, hid the override from the scoped check, and made the plugin insert a duplicate.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F44TnppkycioLyfGN5tewi
…enies

Native prefers the opt-out it persisted itself, so a JS optOut: true could not stop the cold-start drain after an earlier launch opted in; the tap is now consumed without capture.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F44TnppkycioLyfGN5tewi
turnipdabeets added a commit to PostHog/posthog.com that referenced this pull request Sep 15, 2026
The version numbers were written before the releases and the trains went to
other features in between:

- React Native cold-launch capture and the dedupe are in
  @posthog/react-native-plugin 2.9.2 (#4921), not 2.8.2
- the Flutter pre-setup tap fix is in 5.42.1 (#579), not 5.41.1
- the Flutter dedupe is in 5.43.1 (#578), not 5.41.1

posthog-react-native's Expo override is left as TODO_4929_VERSION:
PostHog/posthog-js#4929 has not merged, so no release carries it yet.
@turnipdabeets

Copy link
Copy Markdown
Contributor Author

@dustinbyrne the general fix for the consent point you raised is now up as drafts:

Both add persistOptOut (default true, unchanged behaviour). When false the native SDK stops keeping its own copy of the opt-out decision, so the value the host passes to setup() is the truth and nothing installed under if !config.optOut can run for a user the host considers opted out.

Why a native flag rather than doing it all in the plugin, since that was the obvious cheaper path: the set isn't the one drain we guarded here, it's everything gated on optOut — nine integrations, of which the plugin currently forces off two. And one of them can't be fixed from JS at all: push subscription registration isn't an event, so no beforeSend can stop it, and a token registered in that window is durable server-side state. A plugin-side denylist would also have to be maintained by memory, which is precisely how this arrived — the crash-report replay joined the setup-time drains in March and silently inherited the falsified value.

Verified on device on both platforms with a falsifying control: with the flag off an opted-out user's event is captured (1); with it on it isn't (0).

Your two corrections earlier were both right and are reflected in the write-ups — the $exception comment is about identity attribution rather than consent, and the exposure is the setup window rather than indefinite, with the token registration as the durable tail.

The guards in this PR stay until the plugin adopts the flag and bumps its native floor; they get deleted in that same PR. Tracking in #4965.

@dustinbyrne dustinbyrne left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed 46ded754084f089ac321f749b83148ea784e4616. The new original-consent guard and ordinary-literal handling address the earlier findings. Two cases remain:

Safe native-plugin version boundary

At expoconfig.ts:1092, recovery is enabled by default, but RN still accepts explicitly installed native plugin 2.8.0 through the optional peer >= 2.4.3. That published plugin lacks the consent guard.

The affected combination is a new-architecture Android app with a retained task, a prior persisted native opt-in, and JS configured with persistence: 'memory', defaultOptIn: false, and errorTracking: { autocapture: { nativeCrashes: true } }. Native crash setup still runs while JS denies analytics. If a notification tap arrives before React is ready, the new host override preserves it, and plugin 2.8.0 can enqueue the push-open event before await setup returns and JS reasserts opt-out.

Could we require the first guard-containing native release or skip unsafe installed combinations, and document upgrading both packages? Changesets leaves this non-workspace peer range unchanged, so releasing both changesets does not establish that boundary. The later native persistOptOut work does not change an installed 2.8.0 consumer.

Regression to add: the two-launch mixed-package case must enqueue/send zero denied notification events. Controls should include the guarded plugin, fresh native storage with denial, and an allowed tap. This is source-backed; I did not execute that device regression.

Nested Kotlin block comments

At expoconfig.ts:337–343, taking the first */ does not handle Kotlin's nested comments. This valid host illustrates the problem:

import com.facebook.react.ReactActivity
class MainActivity : ReactActivity() {
  /*
  fun retiredHandler() {
    /* retired implementation */
  }
  */
  override fun onNewIntent(intent: android.content.Intent) {
    super.onNewIntent(intent)
  }
}

The scanner treats the commented-out function's closing } as the class end. It misses the real override below it and inserts a second active override, breaking compilation. Could we track nested Kotlin comments or conservatively skip such source? A transform regression should leave this fixture unchanged with one override and the existing-override warning.

Validation was static inspection and existing CI, including independent review of both findings. The exact mixed-package and nested-comment regressions were not run; existing CI used an integration tree rather than the isolated PR head.

@dustinbyrne
dustinbyrne requested a review from a team September 15, 2026 16:29
…ivity scan

Kotlin nests block comments; Java does not. Taking the first `*/` ended a
commented-out function early, so its closing brace read as the end of the
class, the scoped check missed the real onNewIntent below it, and prebuild
inserted a second override the file no longer compiles with.

Also raise the optional @posthog/react-native-plugin floor to the release
carrying the setup-time consent check, so the recovery this mod enables
cannot pair with a plugin that drains the recovered tap unguarded.
@turnipdabeets

Copy link
Copy Markdown
Contributor Author

Thanks @dustinbyrne — both addressed in 601fd96.

Nested Kotlin block comments

Real bug, fixed. Kotlin nests block comments and Java doesn't, so the scan now tracks depth for kt and keeps the first-*/ behaviour for java. Your fixture is the test verbatim, plus a Java control asserting the non-nesting case still ends at the first close.

Verified it earns its place: reverting to the old first-*/ logic fails the new Kotlin test and nothing else. 123 tests pass in the two expoconfig suites.

Safe native-plugin version boundary

Raised the optional peer floor: @posthog/react-native-plugin >= 2.4.3>= 2.9.3, the release that carries this PR's setup-time consent check. That's the "require the first guard-containing native release" half of what you offered.

I also built the "skip unsafe installed combinations" half — a prebuild check reading the installed plugin's package.json and skipping the override below the floor — and then reverted it, because it's the wrong mechanism here:

  • Changesets bumps package versions at release, so the workspace plugin is 2.8.0 on this branch. A hard floor of 2.9.3 disables the override in-repo and in CI, and would disable it for every user until the release exists. It failed 10 existing tests for exactly that reason.
  • It also couples build behaviour to a hand-maintained constant that has to be re-pinned on every future guard — the same "maintained by memory" failure mode that produced the original bug.

So the peer range is the declarative boundary, and I'd rather not pretend a version gate enforces it. Worth saying plainly: a peer range warns, it doesn't block, so a determined pin can still reach the unsafe combination.

What actually removes the hazard class is not version-gating but deleting the second consent store, which is now up:

With persistOptOut = false the native SDK starts setup() genuinely opted out whenever JS is, so there's no drain to guard and no version to police.

On the regressions you asked for

Not run, and I won't claim otherwise. What I do have on device, on both platforms, is the persistOptOut behaviour with a falsifying control — persisted opt-in plus a host saying opted out captures an event today (including a $screen from an integration installing on its own) and captures nothing with the flag on. The mixed-package two-launch case you describe is a packaging combination I haven't reproduced.

The class declaration was found with a plain regex over raw source while the
brace scan was comment-aware, so the two disagreed: a commented-out
`class MainActivity` above the real one won the regex, and prebuild wrote the
override inside that comment — leaving the real override in place and emitting
uncommented lines at top level, which does not compile.

Mask comments and string/char literals once, then run the declaration match,
the brace scan and the existing-override check over that. One definition of
what counts as code, so a `"}"` field, a commented-out class, and an
onNewIntent named in a comment are invisible to all three.

@dustinbyrne dustinbyrne left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The peer-version boundary and scanner fixes address my feedback. Reviewed 01c199e6bf797042c59f7773882aea9bcf59df34; no remaining code-review concerns. Merge remains subject to passing CI.

@turnipdabeets
turnipdabeets merged commit 87aadf7 into main Sep 15, 2026
65 checks passed
@turnipdabeets
turnipdabeets deleted the fix/rn-android-new-intent-push-open branch September 15, 2026 17:45
turnipdabeets added a commit to PostHog/posthog.com that referenced this pull request Sep 15, 2026
- React Native: the manual/automatic dedupe shipped in plugin 2.9.1, not 2.9.2
  (2.9.2 is the iOS cold-launch hook, which the other references cite correctly)
- Android: the two dedupe mechanisms are not the same. The message-ID dedupe on
  the intent path applies to any FCM notification; only the invocation-id dedupe
  is PostHog-only. The paragraph claimed the second rule for both.
- iOS: on iOS 13 only the push hooks are skipped, not all capture
- iOS: note that the field-based call carries no notification identifier, so a
  genuine re-send inside the window reads as the same tap

Names posthog-react-native 4.74.0 for the Expo override, which is the release
that will carry PostHog/posthog-js#4929. Do not merge before that release.
turnipdabeets added a commit to PostHog/posthog.com that referenced this pull request Sep 15, 2026
* docs(push): correct push open claims that are wrong today

- React Native: iOS taps that cold-launch the app are not captured, and
  automatic capture needs iOS 14 or newer
- React Native: Android captures warm-start taps since plugin 2.6.0, except
  when the process was killed with its task still in the recent apps list
- React Native: promote the notification-delegate requirement to a callout, and
  add the missing $push_notification_opened troubleshooting row
- iOS and Flutter: state the iOS 14 floor for the automatic hooks
- Flutter: name the plugin version instead of native floors the plugin no
  longer declares

* docs(push): document the push open behavior the SDK releases add

- Android, Flutter, iOS: a repeat of a PostHog-sent notification captured in
  the last 5 minutes is skipped, so one tap counts once
- React Native: iOS captures a tap that cold-launches the app, and
  capturePushNotificationOpened: false no longer stops that launch hook -
  document the Info.plist key that does
- React Native: document the MainActivity onNewIntent override for taps that
  arrive after the process was killed, via the Expo config plugin or by hand
- React Native: replace the manual open guidance with the dedupe rule
- Flutter: a tap that lands before setup() is replayed once setup completes
- iOS: the React Native plugin now prewarms open capture too

* docs(push): the dedupe now reaches iOS on both plugins

* docs(push): say per platform what notification content is captured

* docs(push): correct the SDK versions to what actually shipped

The version numbers were written before the releases and the trains went to
other features in between:

- React Native cold-launch capture and the dedupe are in
  @posthog/react-native-plugin 2.9.2 (#4921), not 2.8.2
- the Flutter pre-setup tap fix is in 5.42.1 (#579), not 5.41.1
- the Flutter dedupe is in 5.43.1 (#578), not 5.41.1

posthog-react-native's Expo override is left as TODO_4929_VERSION:
PostHog/posthog-js#4929 has not merged, so no release carries it yet.

* docs(push): correct two dedupe claims and scope the iOS 13 note

- React Native: the manual/automatic dedupe shipped in plugin 2.9.1, not 2.9.2
  (2.9.2 is the iOS cold-launch hook, which the other references cite correctly)
- Android: the two dedupe mechanisms are not the same. The message-ID dedupe on
  the intent path applies to any FCM notification; only the invocation-id dedupe
  is PostHog-only. The paragraph claimed the second rule for both.
- iOS: on iOS 13 only the push hooks are skipped, not all capture
- iOS: note that the field-based call carries no notification identifier, so a
  genuine re-send inside the window reads as the same tap

Names posthog-react-native 4.74.0 for the Expo override, which is the release
that will carry PostHog/posthog-js#4929. Do not merge before that release.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants