feat(push): hold a notification tap that arrives before setup() - #792
Conversation
posthog-ios Compliance ReportDate: 2026-09-09 01:19:40 UTC ✅ All Tests Passed!45/45 tests passed Capture Tests✅ 29/29 tests passed View Details
Feature_Flags Tests✅ 16/16 tests passed View Details
|
A cold launch from a notification tap delivers the response ~150ms in, before a Flutter or React Native host can reach setup() from its own runtime, so the swizzles were not yet installed and the open was lost. Adds prewarmPushNotificationOpenCapture() to install the notification delegate swizzles early and hold one response until the integration subscribes, and warns when no UNUserNotificationCenter delegate exists at all — the case where taps reach nobody and nothing is capturable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd
hedgeLogEnabled defaults to false, so the warning only prints when config.debug is on — a release build sees nothing. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd
7ab145c to
d7b54ae
Compare
…requisite capturePushNotificationOpened's doc never mentioned that iOS reports a tap only through UNUserNotificationCenter.current().delegate — the one surface a native developer reads, and the last one still silent about it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd
Prompt To Fix All With AI### Issue 1
PostHog/PushNotifications/PushNotificationPublisher.swift:139-145
**Prewarm race retains notifications**
The public APIs in this SDK must be thread-safe, but the subscriber-count check and the `isPrewarmed` update happen separately. If the first subscription is added between them, its callback clears `isPrewarmed` before this code sets it back to `true`. Closing that SDK then removes the subscriber without clearing the reopened flag, so a later notification can be buffered without a new prewarm and captured by another `setup()` within 30 seconds.
---
For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.Reviews (1): Last reviewed commit: "docs(push): cut comments that restate th..." | Re-trigger Greptile |
The subscriber-count read in prewarmNotificationResponseCapture() sits outside stateLock — it has to, since the count is behind the multicast's own lock and nesting them the other way inverts the ordering the subscriber-count callback already uses. A subscribe landing between that read and the isPrewarmed write therefore left the flag set with a live subscriber, and nothing cleared it when that subscriber went away, so a later response could be buffered with no live prewarm and replayed into the next setup() inside the TTL. Clearing the flag alongside the un-swizzle converges both racers on "not prewarmed" without nesting the locks. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd
dustinbyrne
left a comment
There was a problem hiding this comment.
One minor finding in PushNotificationPublisher.swift:196–197: the zero-subscriber check and delegate-setter teardown aren’t coordinated with a new subscription.
With multiple SDK instances, A’s setup with push capture disabled could observe zero subscribers, B could then finish optIn() and install interception, and A could subsequently remove it. If the host then assigns a previously unwrapped notification delegate, B would miss automatic push-open captures.
This requires a narrow cross-instance interleaving and doesn’t affect normal single-instance setup. I haven’t reproduced it at runtime. Worth coordinating subscriber ownership with the interception transition and adding a deterministic regression test.
discardPrewarmedNotificationResponseCapture() reads the subscriber count and clears the prewarm flag, then tears the swizzles down — but prewarmPushNotificationOpenCapture() is a public static that does not run under setupLock, so it can set the flag and install between those steps and have the teardown undo it. That leaves a live prewarm with an un-swizzled delegate setter, so a delegate assigned afterwards is never wrapped. Re-checking both signals after the teardown converges on "installed" whichever way the two interleave. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd
|
Both guards exist for races whose only route into the bad state is the race itself, so neither had a regression test. Adds a TESTING-gated hook at the two points where a concurrent caller can overtake — after the subscriber-count read in prewarm and in discard — plus counters for the swizzle transitions, which stay observable where the swizzle state does not (a test runner is never an app, so the bundle guard no-ops it). Each test fails only with its own fix reverted: the prewarm one buffers a response that should have been dropped, the discard one stops at one install where it should re-arm. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd
🔗 Related PRs
One fix, five PRs — three SDKs plus the docs. Each Flutter PR is gated on the native release it depends on.
setup()— ← this PROrder: #792 and PostHog/posthog-android#753 merge and release first → PostHog/posthog-flutter#556 and PostHog/posthog-flutter#557 leave draft and go green on their own once the floors publish → PostHog/posthog.com#19905 last, since posthog.com deploys on merge.
💡 Motivation and Context
Fixes the native half of PostHog/posthog-flutter#555 —
$push_notification_openedis never captured in a stock Flutter app on iOS. Two independent causes, both reproduced on a simulator:UNUserNotificationCenterdelegate, so iOS reports the tap to nobody and our swizzling has nothing to attach to. The integration logsinstalledand then stays silent forever, which is near-impossible to diagnose from outside.didReceivearrives ~150 ms in — about 90 ms before a Dart/JS host can reachsetup(). The swizzles aren't in place yet.Native iOS apps that call
setup()fromdidFinishLaunchingWithOptionsare unaffected by (2) and need none of this.💚 How did you test it?
make test— 907 tests pass.make testOniOSSimulator—** TEST SUCCEEDED **, 190 cases, 0 failures.setup()discard gate. The opted-out one is a real regression test — reverting the fix makes it fail./batchpayload the SDK sent. Before the fix, cold starts captured 0.Testing
Beyond the unit suites, verified on an iPhone 17 Pro simulator (iOS 26.4) through two hosts, with each event read out of the SDK's own
/batchpayload:posthog-ios's own
PostHogExample(setup()indidFinishLaunchingWithOptions, delegate wired) — the configuration this change does not need to alter:A Flutter host, where
setup()runs from Dart ~90ms after the tap is delivered: cold start went from 0 captures to 1, repeatedly, across four rounds.Also checked: with the app's
UNUserNotificationCenterdelegate removed, nothing is captured in any app state and the new warning fires; with it present, no warning.capturePushNotificationOpened: falseinstalls no integration and captures nothing.Re-verified 2026-09-08, after rebasing on
main(posthog-ios is still 3.71.6, so thisminorlands on 3.72.0 and the Flutter floor is unchanged): 907 swift-testing cases and 190 XCTest cases green, and the cold/warm matrix re-run on an iPhone 17 simulator through both hosts — one event each, four for four.Method note: the example apps point at a local HTTP server standing in for the ingestion host, so every assertion above is made against the exact
/batchbody the SDK sent, not a dashboard query.📝 Checklist
If releasing new changes
pnpm changesetto generate a changeset file🤖 Agent context
Autonomy: Human-driven (agent-assisted)
Built with Claude Code (session), driven by @turnipdabeets. Investigation started from posthog-flutter#555 and reproduced both causes on a simulator before any code was written.
Three review/triage rounds ran over the branch, and each caught something material:
setup()→close()a tap was retained in the process-wide publisher and replayed into the nextsetup()within 30s — a silent behaviour change for all posthog-ios users, with a consent dimension. Now the buffer only lives inside the prewarm window.installIntegrations(), whichsetup()only calls when not opted out — so it never ran for anyone with a persisted opt-out. Moved intosetup()with the gate widened.test-ios-simulatorlane (a new test called the iOS-14-only API unguarded).swift teston macOS hides this because Swift raises the arm64 floor to 11.0.Two alternatives were considered and rejected: a
hasEverHadSubscriberlatch to close the window permanently (it would silently kill cold-start capture forever for a Flutter add-to-app host that prewarms afterclose()), and replayinglaunchOptions[.remoteNotification](verified on iOS 26 — that key is not populated for a user tap).Not done here, deliberately: docs. The
posthog_flutterside is PostHog/posthog-flutter#556 and needs a release of this one first.🤖 Generated with Claude Code
https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd