Skip to content

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

Open
turnipdabeets wants to merge 3 commits into
chore/v4-flags-loaded-resultfrom
chore/v4-on-feature-flags-listener
Open

turnipdabeets wants to merge 3 commits into
chore/v4-flags-loaded-resultfrom
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:

  • Callbacks run on the SDK's background remote-config thread, like the config callback. iOS delivers on the main thread; here we document that callers hop to the main thread for UI.
  • 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.

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

Comment on lines +229 to +232
private fun notifyFeatureFlagsListeners(errorsLoading: Boolean) {
val loaded = PostHogFeatureFlagsLoaded(remoteConfig?.getFeatureFlags() ?: emptyMap(), errorsLoading)
lastFeatureFlagsLoaded = loaded
featureFlagsListeners.forEach { deliverFeatureFlags(it, loaded) }

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.

P2 · Discard notifications from loads invalidated by close

When close() runs during a flags request, its completion still calls notifyFeatureFlagsListeners, repopulates the cleared snapshot, and invokes subscribers while disabled. Late subscribers then receive that stale result too. Preserve lifecycle synchronization (AGENTS.md:28); reject notifications from an invalidated setup generation.

Comment on lines +2005 to +2007
val current = lastFeatureFlagsLoaded
if (current != null && current !== listener.lastDelivered) {
deliverFeatureFlags(listener, current)

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.

P2 · Replay flags that have already loaded from persistent cache

With automatic flag loading disabled, getFeatureFlagResult() hydrates persisted flags and marks them loaded, but leaves lastFeatureFlagsLoaded null. A subsequent onFeatureFlags() subscriber receives nothing until an explicit reload, despite current flags being available. Initialize the replay snapshot from already loaded remote-config state.

posthog-server calls its own PostHogOnFeatureFlags directly, and the core stateless client never calls config.onFeatureFlags. Bump posthog-server to minor since it now depends on core 7.0, and document that a bootstrapped onFeatureFlags runs on the setup() thread.
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.

This branch has not been deployed

No deployments
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