Skip to content

feat(push): hold a notification tap that arrives before setup() - #792

Draft
turnipdabeets wants to merge 3 commits into
mainfrom
fix/push-open-prewarm
Draft

feat(push): hold a notification tap that arrives before setup()#792
turnipdabeets wants to merge 3 commits into
mainfrom
fix/push-open-prewarm

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

💡 Motivation and Context

Fixes the native half of PostHog/posthog-flutter#555$push_notification_opened is never captured in a stock Flutter app on iOS. Two independent causes, both reproduced on a simulator:

  1. Nobody is listening. A stock Flutter app sets no UNUserNotificationCenter delegate, so iOS reports the tap to nobody and our swizzling has nothing to attach to. The integration logs installed and then stays silent forever, which is near-impossible to diagnose from outside.
  2. We start too late. On a cold launch from a tap, didReceive arrives ~150 ms in — about 90 ms before a Dart/JS host can reach setup(). The swizzles aren't in place yet.

Native iOS apps that call setup() from didFinishLaunchingWithOptions are unaffected by (2) and need none of this.

💚 How did you test it?

  • make test — 774 tests pass.
  • make testOniOSSimulator** TEST SUCCEEDED **, 190 cases, 0 failures.
  • 9 new unit tests covering the buffer, prewarm idempotency, prewarm-with-a-live-subscriber, and the setup() discard gate. The opted-out one is a real regression test — reverting the fix makes it fail.
  • On device (posthog-flutter example app, iPhone 17 Pro sim / iOS 26.4, built against this branch): 4/4 notification taps captured, one event each — 1 warm start and 3 cold starts, confirmed in ingestion. 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 events confirmed in a real PostHog project:

posthog-ios's own PostHogExample (setup() in didFinishLaunchingWithOptions, delegate wired) — the configuration this change does not need to alter:

Scenario Result
Cold launch from a tap captured
Tap while the app is running captured

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 UNUserNotificationCenter delegate removed, nothing is captured in any app state and the new warning fires; with it present, no warning. capturePushNotificationOpened: false installs no integration and captures nothing.

📝 Checklist

  • I reviewed the submitted code.
  • I added tests to verify the changes.
  • I updated the docs if needed.
  • No breaking change or entry added to the changelog.

If releasing new changes

  • Ran pnpm changeset to 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:

  • Buffering was initially unconditional, so after setup()close() a tap was retained in the process-wide publisher and replayed into the next setup() within 30s — a silent behaviour change for all posthog-ios users, with a consent dimension. Now the buffer only lives inside the prewarm window.
  • The discard was first placed at the end of installIntegrations(), which setup() only calls when not opted out — so it never ran for anyone with a persisted opt-out. Moved into setup() with the gate widened.
  • The branch did not compile in the test-ios-simulator lane (a new test called the iOS-14-only API unguarded). swift test on macOS hides this because Swift raises the arm64 floor to 11.0.

Two alternatives were considered and rejected: a hasEverHadSubscriber latch to close the window permanently (it would silently kill cold-start capture forever for a Flutter add-to-app host that prewarms after close()), and replaying launchOptions[.remoteNotification] (verified on iOS 26 — that key is not populated for a user tap).

Not done here, deliberately: docs. The posthog_flutter side is PostHog/posthog-flutter#556 and needs a release of this one first.

🤖 Generated with Claude Code

https://claude.ai/code/session_012txiHBCZRkShMdE7V25Jrd

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
@github-actions

github-actions Bot commented Sep 1, 2026

Copy link
Copy Markdown
Contributor

posthog-ios Compliance Report

Date: 2026-09-02 16:17:34 UTC
Duration: 208991ms

✅ All Tests Passed!

45/45 tests passed


Capture Tests

29/29 tests passed

View Details
Test Status Duration
Format Validation.Event Has Required Fields 2920ms
Format Validation.Event Has Uuid 2795ms
Format Validation.Event Has Lib Properties 2705ms
Format Validation.Distinct Id Is String 353ms
Format Validation.Token Is Present 357ms
Format Validation.Custom Properties Preserved 348ms
Format Validation.Event Has Timestamp 2808ms
Retry Behavior.Retries On 503 5406ms
Retry Behavior.Does Not Retry On 400 4800ms
Retry Behavior.Does Not Retry On 401 4802ms
Retry Behavior.Respects Retry After Header 7832ms
Retry Behavior.Implements Backoff 21779ms
Retry Behavior.Retries On 500 9281ms
Retry Behavior.Retries On 502 9238ms
Retry Behavior.Retries On 504 9169ms
Retry Behavior.Max Retries Respected 15320ms
Deduplication.Generates Unique Uuids 2872ms
Deduplication.Preserves Uuid On Retry 9178ms
Deduplication.Preserves Uuid And Timestamp On Retry 16658ms
Deduplication.Preserves Uuid And Timestamp On Batch Retry 7849ms
Deduplication.No Duplicate Events In Batch 2880ms
Deduplication.Different Events Have Different Uuids 3123ms
Compression.Sends Gzip When Enabled 2788ms
Batch Format.Uses Proper Batch Structure 2758ms
Batch Format.Flush With No Events Sends Nothing 333ms
Batch Format.Multiple Events Batched Together 2939ms
Error Handling.Does Not Retry On 403 2385ms
Error Handling.Does Not Retry On 413 4804ms
Error Handling.Retries On 408 5252ms

Feature_Flags Tests

16/16 tests passed

View Details
Test Status Duration
Request Payload.Request With Person Properties Device Id 2784ms
Request Payload.Flags Request Uses V2 Query Param 2771ms
Request Payload.Flags Request Hits Flags Path Not Decide 2773ms
Request Payload.Flags Request Omits Authorization Header 2688ms
Request Payload.Token In Flags Body Matches Init 2750ms
Request Payload.Groups Round Trip 2816ms
Request Payload.Groups Default To Empty Object 2819ms
Request Payload.Person Properties Distinct Id Auto Populated When Caller Omits It 2748ms
Request Payload.Disable Geoip False Propagates As Geoip Disable False 2662ms
Request Payload.Disable Geoip Omitted Defaults To False 2832ms
Request Payload.Flag Keys To Evaluate Contains Only Requested Key 2772ms
Request Lifecycle.No Flags Request On Init Alone 61ms
Request Lifecycle.No Flags Request On Normal Capture 2771ms
Request Lifecycle.Two Flag Calls Produce Two Remote Requests 5332ms
Request Lifecycle.Mock Response Value Is Returned To Caller 2843ms
Side Effect Events.Get Feature Flag Captures Feature Flag Called Event 2944ms

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
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.

1 participant