Skip to content

feat: add an onFeatureFlags listener you can unsubscribe - #878

Merged
turnipdabeets merged 8 commits into
chore/v4-remove-deprecatedfrom
chore/v4-on-feature-flags-listener
Oct 29, 2026
Merged

turnipdabeets merged 8 commits into
chore/v4-remove-deprecatedfrom
chore/v4-on-feature-flags-listener

Conversation

@turnipdabeets

@turnipdabeets turnipdabeets commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Note

Stacked on #877, which is stacked on the 4.0 integration PR #871. Merges into chore/v4-flags-loaded-result.

💡 Motivation and Context

Adds onFeatureFlags(), matching posthog-ios 4.0 onFeatureFlags(_:) and posthog-js onFeatureFlags. Today the only way to hear about flag loads after setup is the single PostHogConfig.onFeatureFlags callback, which a screen or library can't add to or remove.

val subscription = PostHog.onFeatureFlags { loaded ->
    if (loaded.variants["new-checkout"] == "test") {
        // ...
    }
}
// later
subscription.unsubscribe()
  • Runs after every feature flags load, with the same PostHogFeatureFlagsLoaded as the config callback (chore!: pass the load result to the feature flags callback #877).
  • If flags are already loaded, the new subscriber gets the current flags once right away, so late subscribers don't wait for the next load.
  • Available on the instance and as the static PostHog.onFeatureFlags. Listeners stay registered across close() and setup().

For reviewers:

  • On Android, listeners run on the main thread, after the getters already return the new values. This follows the on-feature-flags spec ("On SDKs with a main/UI thread, delivery happens on that thread") and matches posthog-ios (feat: add public onFeatureFlags listener with flags, variants and error state posthog-ios#897). The core gets an internal PostHogConfig.featureFlagsListenerExecutor that PostHogAndroid sets to post to the main looper. On the plain JVM, it's unset and listeners run on the remote config thread.
  • config.onFeatureFlags and the reloadFeatureFlags callback still run on the remote config thread. iOS splits it the same way: its reload callback stays off main (chore!: mark off-main public callbacks @Sendable for v4 posthog-ios#914). Moving the existing callbacks to main would be its own breaking change, so it's out of scope here.
  • PostHogInterface gains an abstract method, which breaks third-party implementations of the interface. That's acceptable in 4.0.
  • A load that started before reset() or close() but finishes after is dropped for listeners and not replayed, so listeners never get the previous user's flags after reset(). Each load captures a generation that reset() and close() bump.

💚 How did you test it?

  • ./gradlew spotlessCheck apiCheck :posthog:test :posthog-server:test :posthog-android:testDebugUnitTest pass.
  • PostHogFeatureFlagsTest covers delivery on each load until unsubscribe(), replay to a late listener, listeners surviving close() and setup(), and no delivery from a reload while disabled. A new test checks that a load finishing after close() reaches no listener and isn't replayed; it fails without the fix. PostHogBootstrapTest covers replay of bootstrapped flags.
  • Two tests cover main-thread delivery. PostHogFeatureFlagsTest checks that listeners and late-listener replay run on the listener executor. It also checks that an update queued there is delivered once even when its replay is queued alongside it, and that unsubscribing drops it. PostHogAndroidTest checks that Android posts listeners to the main looper. Each fails without its fix.
  • Pixel 9 emulator (API 37) with the sample app against a real project. Before this change, a listener calling setText on an attached view logged CalledFromWrongThreadException, and LiveData.setValue threw (the SDK caught it, so the update was lost). After it, every delivery ran on main, both worked, and the config and reload callbacks stayed on PostHogRemoteConfigThread.

📝 Checklist

  • All commits are signed (see Commit signing)
  • 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)

Claude Code (Claude Opus 5.5) implemented the listener for flags API parity with iOS 4.0 and Flutter 6, after a cross-SDK review of the flags APIs.

🤖 Generated with Claude Code

@posthog posthog Bot 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.

PostHog Review · Standard · reviewed b2fbd8b: 2 Should fix.

See this review and its findings in PostHog Review. Comment @posthog review --deep for a Deep review. A Deep review takes longer and looks deeper than a Standard review.

@turnipdabeets
turnipdabeets force-pushed the chore/v4-on-feature-flags-listener branch from b2fbd8b to b328ebe Compare October 23, 2026 22:25
@turnipdabeets
turnipdabeets force-pushed the chore/v4-flags-loaded-result branch from 6d389ab to 4469ad4 Compare October 26, 2026 16:29
@turnipdabeets
turnipdabeets force-pushed the chore/v4-on-feature-flags-listener branch 2 times, most recently from 46c6dd1 to 777d2c4 Compare October 26, 2026 23:05
@turnipdabeets
turnipdabeets force-pushed the chore/v4-flags-loaded-result branch from 4469ad4 to 3e7794c Compare October 26, 2026 23:05
@turnipdabeets
turnipdabeets force-pushed the chore/v4-on-feature-flags-listener branch from 777d2c4 to e293ef7 Compare October 27, 2026 13:58
@turnipdabeets
turnipdabeets force-pushed the chore/v4-flags-loaded-result branch from e9eb0db to 46fca5a Compare October 27, 2026 15:48
@turnipdabeets
turnipdabeets force-pushed the chore/v4-on-feature-flags-listener branch 2 times, most recently from 8976a71 to 2dfb42a Compare October 27, 2026 16:05
@turnipdabeets
turnipdabeets force-pushed the chore/v4-flags-loaded-result branch from 3f4acd8 to 5dcba77 Compare October 27, 2026 17:20
@turnipdabeets
turnipdabeets force-pushed the chore/v4-on-feature-flags-listener branch from 2dfb42a to 1aa1f7e Compare October 27, 2026 17:20

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

After a failed first real load, onFeatureFlags can emit and replay an empty map even when persisted flags exist and the existing completion returns them. This concerns the failed-load payload, not the accepted cache-only readiness policy.

AI-assisted review.

@dustinbyrne
dustinbyrne requested a review from a team October 27, 2026 21:33
@turnipdabeets
turnipdabeets force-pushed the chore/v4-on-feature-flags-listener branch 2 times, most recently from 8f4a729 to 57533d7 Compare October 28, 2026 15:09

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

Closing the SDK from the bootstrap config callback still lets setup deliver and replay observer flags while disabled. A public-entry regression reproduces this; the earlier failed-load payload fix remains correct. The Android listener's delivery-thread contract also needs reconciliation with current sdk-specs wording.

AI-assisted review.

@dustinbyrne
dustinbyrne requested a review from a team October 28, 2026 19:30
Base automatically changed from chore/v4-flags-loaded-result to chore/v4-remove-deprecated October 28, 2026 20:38
turnipdabeets and others added 4 commits October 28, 2026 16:39
Registers a PostHogFeatureFlagsCallback that runs after every feature flags load, replays the current flags to late subscribers, and returns a PostHogFeatureFlagsSubscription, matching posthog-ios onFeatureFlags(_:).
A load that started before reset() or close() could finish afterwards, reach listeners, and be replayed to late subscribers, including the previous user's flags after reset(). Each load now captures a generation that reset() and close() bump, and listener updates from an older generation are dropped.
A failed first load built the listener snapshot before the flag cache was
hydrated, so listeners and late replays got {} while the completion and
getters returned the persisted flags. Hydrate before either callback runs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… closes the SDK

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@turnipdabeets
turnipdabeets force-pushed the chore/v4-on-feature-flags-listener branch from 69c79e8 to 9f4d709 Compare October 28, 2026 20:39
Listeners usually update UI. On the remote config thread, a view update hits
CalledFromWrongThreadException and LiveData.setValue throws, which the SDK
catches, so the update is lost. This matches the on-feature-flags spec and
posthog-ios. The config and reload callbacks keep running in the background.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@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 bootstrap-close fix and Android main-thread delivery address the previous findings. One setup-close path remains: a public integration can close the client during installation, after which startup flags are still delivered and replayed while disabled. The new singleton/factory regression reproduces it. Optional threading-doc clarification is inline.

AI-assisted review.

@dustinbyrne
dustinbyrne requested a review from a team October 29, 2026 17:02
turnipdabeets and others added 2 commits October 29, 2026 15:05
…the SDK

Capture the flags generation right after setup enables the SDK, before
any user callout (integration install, session hooks, bootstrap config
callback) that could close() it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

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

Approved with an optional note on bootstrap observer ordering when a custom integration starts a reload during installation.

AI-assisted review.

An integration that calls reloadFeatureFlags() during install queued its
/flags request on the serial remote config executor ahead of setup's
bootstrap notification, so onFeatureFlags listeners waited on the network
even though bootstrapped flags were ready.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@turnipdabeets
turnipdabeets merged commit e6894bf into chore/v4-remove-deprecated Oct 29, 2026
11 checks passed
@turnipdabeets
turnipdabeets deleted the chore/v4-on-feature-flags-listener branch October 29, 2026 21:15
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