From 00643ec1531f35d7b47840f45c1908faed69016a Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 7 Oct 2026 16:04:18 -0400 Subject: [PATCH 01/15] chore!: adopt posthog-ios 4.0 - Require posthog-ios 4.x. - Adapt to the reloadFeatureFlags(_:) callback that now passes a PostHogFeatureFlagsLoaded (PostHog/posthog-ios#913). - Note the payload decoding changes from PostHog/posthog-ios#903. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/v6-posthog-ios-4.md | 7 +++++++ posthog_flutter/darwin/posthog_flutter.podspec | 2 +- posthog_flutter/darwin/posthog_flutter/Package.swift | 2 +- .../Sources/posthog_flutter/PosthogFlutterPlugin.swift | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) create mode 100644 .changeset/v6-posthog-ios-4.md diff --git a/.changeset/v6-posthog-ios-4.md b/.changeset/v6-posthog-ios-4.md new file mode 100644 index 00000000..ae84b6fe --- /dev/null +++ b/.changeset/v6-posthog-ios-4.md @@ -0,0 +1,7 @@ +--- +"posthog_flutter": major +--- + +- **Breaking:** adopt `posthog-ios` 4.x, which requires Xcode 26 +- **Breaking:** return `null` instead of the raw string for a malformed, empty, or whitespace-only `getFeatureFlagResult()` payload on iOS and macOS +- **Breaking:** keep string payloads passed to `PostHogBootstrapConfig.featureFlagPayloads`, such as `"123"`, as strings on iOS and macOS instead of decoding them to numbers or booleans diff --git a/posthog_flutter/darwin/posthog_flutter.podspec b/posthog_flutter/darwin/posthog_flutter.podspec index 98e3a7cf..d50d58d6 100644 --- a/posthog_flutter/darwin/posthog_flutter.podspec +++ b/posthog_flutter/darwin/posthog_flutter.podspec @@ -21,7 +21,7 @@ Postog flutter plugin s.ios.dependency 'Flutter' s.osx.dependency 'FlutterMacOS' - s.dependency 'PostHog', '>= 3.84.0', '< 4.0.0' + s.dependency 'PostHog', '>= 4.0.0', '< 5.0.0' s.ios.deployment_target = '15.0' s.osx.deployment_target = '11.0' diff --git a/posthog_flutter/darwin/posthog_flutter/Package.swift b/posthog_flutter/darwin/posthog_flutter/Package.swift index 1cf4470c..5d2d9474 100644 --- a/posthog_flutter/darwin/posthog_flutter/Package.swift +++ b/posthog_flutter/darwin/posthog_flutter/Package.swift @@ -14,7 +14,7 @@ let package = Package( ], dependencies: [ .package(name: "FlutterFramework", path: "../FlutterFramework"), - .package(url: "https://github.com/PostHog/posthog-ios", "3.84.0" ..< "4.0.0"), + .package(url: "https://github.com/PostHog/posthog-ios", "4.0.0" ..< "5.0.0"), ], targets: [ .target( diff --git a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift index 3d32f8a3..2e87970c 100644 --- a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift +++ b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift @@ -1627,7 +1627,7 @@ extension PosthogFlutterPlugin { private func reloadFeatureFlags(_ result: @escaping FlutterResult) { // Resolve the Dart Future only once flags have actually finished loading. // The native callback fires on a background thread, so hop to main for Flutter. - PostHogSDK.shared.reloadFeatureFlags { + PostHogSDK.shared.reloadFeatureFlags { _ in DispatchQueue.main.async { result(nil) } From ddbe08ebd57c2650459014d2ffdc5489f1f6279b Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 7 Oct 2026 16:33:41 -0400 Subject: [PATCH 02/15] docs(changeset): note posthog-ios 4.0 drops lifecycle version and build PostHog/posthog-ios#905 removes the legacy version/build properties from lifecycle events in 4.0, so Flutter iOS and macOS apps stop sending them. Co-Authored-By: Claude Opus 5.5 (1M context) --- .changeset/v6-posthog-ios-4.md | 1 + 1 file changed, 1 insertion(+) diff --git a/.changeset/v6-posthog-ios-4.md b/.changeset/v6-posthog-ios-4.md index ae84b6fe..39b171fa 100644 --- a/.changeset/v6-posthog-ios-4.md +++ b/.changeset/v6-posthog-ios-4.md @@ -5,3 +5,4 @@ - **Breaking:** adopt `posthog-ios` 4.x, which requires Xcode 26 - **Breaking:** return `null` instead of the raw string for a malformed, empty, or whitespace-only `getFeatureFlagResult()` payload on iOS and macOS - **Breaking:** keep string payloads passed to `PostHogBootstrapConfig.featureFlagPayloads`, such as `"123"`, as strings on iOS and macOS instead of decoding them to numbers or booleans +- **Breaking:** stop sending the legacy `version` and `build` properties on `Application Installed`, `Application Updated` and `Application Opened` on iOS and macOS — use `$app_version` and `$app_build`. Android still sends them until `posthog-android` drops them From 69a3ef830d35294a0a1f7fdd096e0e6a98a3ac69 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 23 Oct 2026 12:38:30 -0400 Subject: [PATCH 03/15] chore(ios): drive onFeatureFlags from the posthog-ios onFeatureFlags listener posthog-ios 4.0 removes the didReceiveFeatureFlags notification. --- .../posthog_flutter/PosthogFlutterPlugin.swift | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift index 2e87970c..ba04bf20 100644 --- a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift +++ b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift @@ -43,6 +43,7 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { private static var instance: PosthogFlutterPlugin? private var channel: FlutterMethodChannel? + private var featureFlagsSubscription: PostHogFeatureFlagsSubscription? // Mint route for pushIdentityProvider, anchored to the engine whose setup() // installed the provider. A live owner is never displaced; provider-enabled @@ -61,15 +62,13 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { override init() { super.init() - NotificationCenter.default.addObserver( - self, - selector: #selector(featureFlagsDidUpdate), - name: PostHogSDK.didReceiveFeatureFlags, - object: nil - ) + featureFlagsSubscription = PostHogSDK.shared.onFeatureFlags { [weak self] _ in + self?.featureFlagsDidUpdate() + } } deinit { + featureFlagsSubscription?.unsubscribe() NotificationCenter.default.removeObserver(self) } @@ -88,7 +87,7 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { registrar.addMethodCallDelegate(instance, channel: methodChannel) } - @objc func featureFlagsDidUpdate() { + func featureFlagsDidUpdate() { invokeFlutterMethod("onFeatureFlagsCallback", arguments: [String: Any]()) } From d35843022c722e1ef699f1ac661f699d168a3117 Mon Sep 17 00:00:00 2001 From: Anna Garcia <11654201+turnipdabeets@users.noreply.github.com> Date: Thu, 29 Oct 2026 12:01:18 -0400 Subject: [PATCH 04/15] chore!: adopt posthog-android 4.0 (#663) * chore!: adopt posthog-android 4.0 * chore!: pass the loaded flags to onFeatureFlags and reloadFeatureFlags (#664) * chore!: pass the loaded flags to onFeatureFlags and reloadFeatureFlags onFeatureFlags and reloadFeatureFlags now carry a PostHogFeatureFlagsLoaded with the enabled flags, their values and whether loading failed, matching posthog-ios 4.0 and posthog-android 4.0. On web, reloadFeatureFlags completes right away with the flags loaded so far, because posthog-js doesn't report when a reload finishes. * fix: complete the web reloadFeatureFlags with the reloaded flags Wait for the next posthog-js onFeatureFlags call instead of returning the flags already loaded, so a failed reload reports errorsLoading. Complete right away when flags are disabled, and after twice the flags request timeout when posthog-js skips the request without calling back. * chore: use PostHogFeatureFlagsCallback from posthog-android 4.0 * chore: fix ktlint function-signature * fix: await posthog-js reloadFeatureFlagsAsync on web The next onFeatureFlags callback can carry a response from a request already in flight, so reloadFeatureFlags() could complete with flags from before the reload. posthog-js 1.438.2+ settles reloadFeatureFlagsAsync() after this reload's own request; use it when present and keep the callback path for older posthog-js. * docs: pass the loaded flags in the setup example * docs: drop best-effort web note from the person and group property methods * fix(web): wait for posthog-js flags retries before reporting a reload failure posthog-js retries a timed-out /flags request once by default, 500 ms later, inside the same reload. The older-JS deadline of twice the request timeout reported cached flags with errorsLoading set before a successful retry landed. * docs: note where onFeatureFlags and errorsLoading differ on Android and web * docs: describe the older posthog-js reload timeout as the full retry budget Co-Authored-By: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --------- Co-authored-by: Claude Opus 5.5 --- .changeset/v6-flags-loaded-result.md | 6 + .changeset/v6-posthog-android-4.md | 5 + api/posthog_flutter.api.json | 93 ++++++++++- example/lib/main.dart | 7 +- posthog_flutter/android/build.gradle | 2 +- .../posthog/flutter/PosthogFlutterPlugin.kt | 13 +- .../PosthogFlutterPlugin.swift | 16 +- posthog_flutter/lib/posthog_flutter.dart | 1 + posthog_flutter/lib/posthog_flutter_web.dart | 12 +- .../lib/src/core/posthog_core.dart | 7 + .../lib/src/feature_flags_loaded.dart | 53 +++++++ posthog_flutter/lib/src/posthog.dart | 47 ++++-- posthog_flutter/lib/src/posthog_config.dart | 20 ++- .../lib/src/posthog_flutter_desktop.dart | 14 +- .../lib/src/posthog_flutter_io.dart | 12 +- .../posthog_flutter_platform_interface.dart | 10 +- .../lib/src/posthog_flutter_web_handler.dart | 92 ++++++++++- .../lib/src/utils/channel_serialization.dart | 16 ++ .../test/posthog_flutter_desktop_test.dart | 38 ++++- .../test/posthog_flutter_io_test.dart | 106 ++++--------- ...sthog_flutter_platform_interface_fake.dart | 4 +- .../posthog_flutter_web_handler_test.dart | 146 ++++++++++++++++++ posthog_flutter/test/posthog_test.dart | 2 +- pubspec.lock | 28 ++-- 24 files changed, 597 insertions(+), 153 deletions(-) create mode 100644 .changeset/v6-flags-loaded-result.md create mode 100644 .changeset/v6-posthog-android-4.md create mode 100644 posthog_flutter/lib/src/feature_flags_loaded.dart diff --git a/.changeset/v6-flags-loaded-result.md b/.changeset/v6-flags-loaded-result.md new file mode 100644 index 00000000..03501e51 --- /dev/null +++ b/.changeset/v6-flags-loaded-result.md @@ -0,0 +1,6 @@ +--- +"posthog_flutter": major +--- + +- **Breaking:** `PostHogConfig.onFeatureFlags` now receives a `PostHogFeatureFlagsLoaded` with the enabled flags, their values and whether loading failed. Update callbacks from `() { ... }` to `(loaded) { ... }` (or `(_) { ... }`) +- **Breaking:** `reloadFeatureFlags()` now returns a `Future` with the reloaded flags diff --git a/.changeset/v6-posthog-android-4.md b/.changeset/v6-posthog-android-4.md new file mode 100644 index 00000000..3ec91401 --- /dev/null +++ b/.changeset/v6-posthog-android-4.md @@ -0,0 +1,5 @@ +--- +"posthog_flutter": major +--- + +- **Breaking:** adopt `posthog-android` 4.x diff --git a/api/posthog_flutter.api.json b/api/posthog_flutter.api.json index 1499e991..71c718a4 100644 --- a/api/posthog_flutter.api.json +++ b/api/posthog_flutter.api.json @@ -802,7 +802,7 @@ "name": "reloadFeatureFlags", "parameters": [], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", - "returnTypeName": "Future", + "returnTypeName": "Future", "type": "method", "typeParameterNames": [] }, @@ -1855,7 +1855,7 @@ "name": "reloadFeatureFlags", "parameters": [], "relativePath": "lib/src/posthog_flutter_platform_interface.dart", - "returnTypeName": "Future", + "returnTypeName": "Future", "type": "method", "typeParameterNames": [] }, @@ -2301,7 +2301,7 @@ "isRequired": false, "name": "onFeatureFlags", "relativePath": "lib/src/posthog_config.dart", - "typeName": "void Function()?" + "typeName": "void Function(PostHogFeatureFlagsLoaded)?" }, { "isDeprecated": false, @@ -2582,7 +2582,7 @@ "isWriteable": true, "name": "onFeatureFlags", "relativePath": "lib/src/posthog_config.dart", - "typeName": "void Function()?" + "typeName": "void Function(PostHogFeatureFlagsLoaded)?" }, { "entryPoints": [], @@ -4522,7 +4522,7 @@ "name": "reloadFeatureFlags", "parameters": [], "relativePath": "lib/src/posthog.dart", - "returnTypeName": "Future", + "returnTypeName": "Future", "type": "method", "typeParameterNames": [] }, @@ -6026,6 +6026,89 @@ "Object" ], "typeParameterNames": [] + }, + { + "entryPoints": [ + "posthog_flutter.dart" + ], + "executableDeclarations": [ + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isStatic": false, + "name": "new", + "parameters": [ + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": true, + "name": "variants", + "relativePath": "lib/src/feature_flags_loaded.dart", + "typeName": "Map" + }, + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": true, + "isRequired": true, + "name": "errorsLoading", + "relativePath": "lib/src/feature_flags_loaded.dart", + "typeName": "bool" + } + ], + "relativePath": "lib/src/feature_flags_loaded.dart", + "returnTypeName": "PostHogFeatureFlagsLoaded", + "type": "constructor", + "typeParameterNames": [] + } + ], + "fieldDeclarations": [ + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isReadable": true, + "isStatic": false, + "isWriteable": false, + "name": "flags", + "relativePath": "lib/src/feature_flags_loaded.dart", + "typeName": "List" + }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isReadable": true, + "isStatic": false, + "isWriteable": false, + "name": "variants", + "relativePath": "lib/src/feature_flags_loaded.dart", + "typeName": "Map" + }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isReadable": true, + "isStatic": false, + "isWriteable": false, + "name": "errorsLoading", + "relativePath": "lib/src/feature_flags_loaded.dart", + "typeName": "bool" + } + ], + "isDeprecated": false, + "isExperimental": false, + "isRequired": false, + "isSealed": false, + "name": "PostHogFeatureFlagsLoaded", + "relativePath": "lib/src/feature_flags_loaded.dart", + "superTypeNames": [ + "Object" + ], + "typeParameterNames": [] } ], "iosPlatformConstraints": null, diff --git a/example/lib/main.dart b/example/lib/main.dart index 378b7c3c..e4599572 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -21,8 +21,11 @@ Future main() async { final config = PostHogConfig( 'phc_6lqCaCDCBEWdIGieihq5R2dZpPVbAUFISA75vFZow06', ); - config.onFeatureFlags = () { - debugPrint('[PostHog] Feature flags loaded!'); + config.onFeatureFlags = (loaded) { + debugPrint( + '[PostHog] Feature flags loaded: ${loaded.variants}, ' + 'errorsLoading: ${loaded.errorsLoading}', + ); }; config.beforeSend = [ diff --git a/posthog_flutter/android/build.gradle b/posthog_flutter/android/build.gradle index 2ae6e792..fd123302 100644 --- a/posthog_flutter/android/build.gradle +++ b/posthog_flutter/android/build.gradle @@ -67,7 +67,7 @@ android { testImplementation 'org.robolectric:robolectric:4.14.1' testImplementation 'junit:junit:4.13.2' testRuntimeOnly 'org.junit.vintage:junit-vintage-engine:5.10.1' - implementation 'com.posthog:posthog-android:[3.71.0,4.0.0)' + implementation 'com.posthog:posthog-android:[4.0.0,5.0.0)' } testOptions { diff --git a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index f3c01a69..91f4eb8e 100644 --- a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -27,7 +27,8 @@ import com.posthog.PostHogBeforeSend import com.posthog.PostHogBootstrapConfig import com.posthog.PostHogCompression import com.posthog.PostHogConfig -import com.posthog.PostHogOnFeatureFlags +import com.posthog.PostHogFeatureFlagsCallback +import com.posthog.PostHogFeatureFlagsLoaded import com.posthog.android.PostHogAndroid import com.posthog.android.PostHogAndroidConfig import com.posthog.android.internal.getApplicationInfo @@ -814,9 +815,9 @@ class PosthogFlutterPlugin : } onFeatureFlags = - PostHogOnFeatureFlags { + PostHogFeatureFlagsCallback { loaded -> Log.i("PostHogFlutter", "Android onFeatureFlags triggered. Notifying Dart.") - invokeFlutterMethod("onFeatureFlagsCallback", emptyMap()) + invokeFlutterMethod("onFeatureFlagsCallback", loaded.toFlutterMap()) } } @@ -1929,9 +1930,9 @@ class PosthogFlutterPlugin : // Resolve the Dart Future only once flags have actually finished // loading. The native callback fires on a background thread, so the // result must be posted back to the main thread for Flutter. - PostHog.reloadFeatureFlags { + PostHog.reloadFeatureFlags { loaded -> Handler(Looper.getMainLooper()).post { - result.success(null) + result.success(loaded.toFlutterMap()) } } } catch (e: Throwable) { @@ -2232,6 +2233,8 @@ class PosthogFlutterPlugin : } } + private fun PostHogFeatureFlagsLoaded.toFlutterMap(): Map = mapOf("variants" to variants, "errorsLoading" to errorsLoading) + private fun invokeFlutterMethod( method: String, arguments: Any? = null, diff --git a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift index ba04bf20..bb88b762 100644 --- a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift +++ b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift @@ -62,8 +62,8 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { override init() { super.init() - featureFlagsSubscription = PostHogSDK.shared.onFeatureFlags { [weak self] _ in - self?.featureFlagsDidUpdate() + featureFlagsSubscription = PostHogSDK.shared.onFeatureFlags { [weak self] loaded in + self?.featureFlagsDidUpdate(loaded) } } @@ -87,8 +87,12 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { registrar.addMethodCallDelegate(instance, channel: methodChannel) } - func featureFlagsDidUpdate() { - invokeFlutterMethod("onFeatureFlagsCallback", arguments: [String: Any]()) + func featureFlagsDidUpdate(_ loaded: PostHogFeatureFlagsLoaded) { + invokeFlutterMethod("onFeatureFlagsCallback", arguments: Self.featureFlagsLoadedMap(loaded)) + } + + private static func featureFlagsLoadedMap(_ loaded: PostHogFeatureFlagsLoaded) -> [String: Any] { + ["variants": loaded.variants, "errorsLoading": loaded.errorsLoading] } private let dispatchQueue = DispatchQueue(label: "com.posthog.PosthogFlutterPlugin", @@ -1626,9 +1630,9 @@ extension PosthogFlutterPlugin { private func reloadFeatureFlags(_ result: @escaping FlutterResult) { // Resolve the Dart Future only once flags have actually finished loading. // The native callback fires on a background thread, so hop to main for Flutter. - PostHogSDK.shared.reloadFeatureFlags { _ in + PostHogSDK.shared.reloadFeatureFlags { loaded in DispatchQueue.main.async { - result(nil) + result(Self.featureFlagsLoadedMap(loaded)) } } } diff --git a/posthog_flutter/lib/posthog_flutter.dart b/posthog_flutter/lib/posthog_flutter.dart index a95b37a5..13307a0e 100644 --- a/posthog_flutter/lib/posthog_flutter.dart +++ b/posthog_flutter/lib/posthog_flutter.dart @@ -2,6 +2,7 @@ library posthog_flutter; export 'src/feature_flag_result.dart'; +export 'src/feature_flags_loaded.dart'; export 'src/logs/posthog_log_record.dart'; export 'src/logs/posthog_log_severity.dart'; export 'src/logs/posthog_logger.dart' hide CaptureLog; diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index 2e783f2b..3f09dbfb 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -11,6 +11,7 @@ import 'package:posthog_flutter/src/utils/channel_serialization.dart'; import 'package:posthog_flutter/src/utils/property_normalizer.dart'; import 'src/feature_flag_result.dart'; +import 'src/feature_flags_loaded.dart'; import 'src/logs/posthog_log_severity.dart'; import 'src/posthog_config.dart'; import 'src/posthog_constants.dart'; @@ -129,10 +130,12 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { final dartCallback = config.onFeatureFlags!; // JS SDK calls with: (flags: string[], variants: Record, context?: {errorsLoading}) - // We ignore the JS parameters and just invoke the void callback final jsCallback = (JSArray jsFlags, JSObject jsFlagVariants, [JSObject? jsContext]) { - dartCallback(); + dartCallback(featureFlagsLoadedFromMap({ + 'variants': jsFlagVariants.dartify(), + 'errorsLoading': (jsContext?.dartify() as Map?)?['errorsLoading'], + })); }.toJS; ph.onFeatureFlags(jsCallback); @@ -393,8 +396,9 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { } @override - Future reloadFeatureFlags() async { - return handleWebMethodCall(const MethodCall('reloadFeatureFlags')); + Future reloadFeatureFlags() async { + return featureFlagsLoadedFromMap( + await handleWebMethodCall(const MethodCall('reloadFeatureFlags'))); } @override diff --git a/posthog_flutter/lib/src/core/posthog_core.dart b/posthog_flutter/lib/src/core/posthog_core.dart index 62f9b566..b833f2ab 100644 --- a/posthog_flutter/lib/src/core/posthog_core.dart +++ b/posthog_flutter/lib/src/core/posthog_core.dart @@ -5,6 +5,7 @@ import 'dart:math'; import 'package:meta/meta.dart'; import '../feature_flag_result.dart'; +import '../feature_flags_loaded.dart'; import '../posthog_config.dart'; import 'feature_flag_utils.dart'; import 'feature_flags.dart'; @@ -924,6 +925,12 @@ abstract class PostHogCore extends PostHogCoreStateless { await wrap(_flagsAsync); } + /// The enabled flags currently served, and whether the latest load failed. + PostHogFeatureFlagsLoaded featureFlagsLoaded() => PostHogFeatureFlagsLoaded( + variants: _getFeatureFlags() ?? const {}, + errorsLoading: _getStoredFlagDetails()?.requestError != null, + ); + /// Registers a callback for when feature flags are loaded. A callback /// registered right after the client is created also hears about the flags /// it starts with, bootstrapped or cached. Returns an unsubscribe function. diff --git a/posthog_flutter/lib/src/feature_flags_loaded.dart b/posthog_flutter/lib/src/feature_flags_loaded.dart new file mode 100644 index 00000000..0a8c196b --- /dev/null +++ b/posthog_flutter/lib/src/feature_flags_loaded.dart @@ -0,0 +1,53 @@ +/// The feature flags passed to [PostHogConfig.onFeatureFlags] and returned by +/// [Posthog.reloadFeatureFlags]. +/// +/// Only enabled flags are included. When the latest load failed, +/// [errorsLoading] is `true` and the flags are the last known values, which +/// can be empty. +/// +/// **Example:** +/// ```dart +/// final config = PostHogConfig('') +/// ..onFeatureFlags = (loaded) { +/// if (loaded.errorsLoading) { +/// // The values are the last known (cached) flags. +/// } +/// if (loaded.variants['new-checkout'] == 'test') { +/// // Show the test variant. +/// } +/// }; +/// ``` +class PostHogFeatureFlagsLoaded { + /// The keys of the enabled feature flags. + List get flags => List.unmodifiable(variants.keys); + + /// The values of the enabled feature flags, keyed by flag key. + /// + /// A value is `true` for an enabled boolean flag, or the variant [String] + /// for a multivariate flag. Disabled flags are not included. + final Map variants; + + /// Whether the latest attempt to load feature flags failed. + /// + /// When it did, [flags] and [variants] hold the last known values. + /// + /// **Flutter web:** when [PostHogConfig.onFeatureFlags] is first set, it is + /// called right away with the current flags and `false`, even if + /// posthog-js's last load before that failed. + final bool errorsLoading; + + /// Creates the loaded feature flags from [variants], keeping only enabled + /// flags: `true`, or a non-empty variant string. + PostHogFeatureFlagsLoaded({ + required Map variants, + required this.errorsLoading, + }) : variants = Map.unmodifiable({ + for (final MapEntry(:key, :value) in variants.entries) + if (value == true || (value is String && value.isNotEmpty)) + key: value!, + }); + + @override + String toString() => + 'PostHogFeatureFlagsLoaded(variants: $variants, errorsLoading: $errorsLoading)'; +} diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index 495f1927..d45b5699 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -5,6 +5,7 @@ import 'package:meta/meta.dart'; import 'package:posthog_flutter/src/error_tracking/posthog_error_tracking_autocapture_integration.dart'; import 'package:posthog_flutter/src/error_tracking/posthog_exception.dart'; import 'feature_flag_result.dart'; +import 'feature_flags_loaded.dart'; import 'logs/posthog_log_record.dart'; import 'logs/posthog_log_severity.dart'; import 'logs/posthog_logger.dart'; @@ -58,7 +59,7 @@ class Posthog { /// ```dart /// final config = PostHogConfig('YOUR_PROJECT_TOKEN'); /// config.host = 'YOUR_POSTHOG_HOST'; - /// config.onFeatureFlags = () { + /// config.onFeatureFlags = (loaded) { /// // Feature flags are now loaded, you can read flag values here /// }; /// await Posthog().setup(config); @@ -489,8 +490,30 @@ class Posthog { /// Reloads feature flags for the current user. /// - /// Returns a [Future] that completes when the reload request has been queued. - Future reloadFeatureFlags() => _posthog.reloadFeatureFlags(); + /// Returns a [Future] that completes when the reload finishes, with the + /// enabled flags and whether loading failed. When it failed, + /// [PostHogFeatureFlagsLoaded.errorsLoading] is `true` and the flags are the + /// last known values. + /// + /// **Flutter web:** with posthog-js 1.438.2 or later, completes once this + /// reload's request settles; if posthog-js skips it (flags disabled, + /// reloading paused, or after repeated network failures), it completes right + /// away with the current flags and `errorsLoading: true`. Older posthog-js + /// completes with the next flags it reports, and when it skips the request, + /// with `errorsLoading: true` once every request attempt posthog-js may + /// make could have timed out: `feature_flag_request_max_retries + 2` times + /// its `feature_flag_request_timeout_ms`, plus 500 ms per retry (9.5 s with + /// the defaults; right away when flags are disabled). + /// + /// **Example:** + /// ```dart + /// final loaded = await Posthog().reloadFeatureFlags(); + /// if (!loaded.errorsLoading && loaded.flags.contains('new-checkout')) { + /// // The flag is enabled. + /// } + /// ``` + Future reloadFeatureFlags() => + _posthog.reloadFeatureFlags(); /// Sets person properties that are used only for feature flag evaluation. /// @@ -506,9 +529,9 @@ class Posthog { /// /// Set [reloadFeatureFlags] to `false` to skip reloading flags after updating /// the properties (defaults to `true`). When `true`, the returned [Future] - /// awaits the reload before completing; on iOS and Android this means flags - /// have finished loading, so the next [getFeatureFlag] / [getFeatureFlagResult] - /// reflects the updated properties. On web the reload is best-effort. + /// awaits the reload before completing, so the next [getFeatureFlag] / + /// [getFeatureFlagResult] reflects the updated properties. On web, see + /// [Posthog.reloadFeatureFlags] for which posthog-js versions wait for it. /// /// **Example:** /// ```dart @@ -536,8 +559,8 @@ class Posthog { /// /// Set [reloadFeatureFlags] to `false` to skip reloading flags after clearing /// the properties (defaults to `true`). When `true`, the returned [Future] - /// awaits the reload before completing (on iOS/Android, after flags finish - /// loading; on web, best-effort). + /// awaits the reload before completing. On web, see + /// [Posthog.reloadFeatureFlags] for which posthog-js versions wait for it. Future resetPersonPropertiesForFlags({ bool reloadFeatureFlags = true, }) async { @@ -560,8 +583,8 @@ class Posthog { /// /// Set [reloadFeatureFlags] to `false` to skip reloading flags after updating /// the properties (defaults to `true`). When `true`, the returned [Future] - /// awaits the reload before completing (on iOS/Android, after flags finish - /// loading; on web, best-effort). + /// awaits the reload before completing. On web, see + /// [Posthog.reloadFeatureFlags] for which posthog-js versions wait for it. /// /// **Example:** /// ```dart @@ -592,8 +615,8 @@ class Posthog { /// /// Set [reloadFeatureFlags] to `false` to skip reloading flags after clearing /// the properties (defaults to `true`). When `true`, the returned [Future] - /// awaits the reload before completing (on iOS/Android, after flags finish - /// loading; on web, best-effort). + /// awaits the reload before completing. On web, see + /// [Posthog.reloadFeatureFlags] for which posthog-js versions wait for it. Future resetGroupPropertiesForFlags({ String? groupType, bool reloadFeatureFlags = true, diff --git a/posthog_flutter/lib/src/posthog_config.dart b/posthog_flutter/lib/src/posthog_config.dart index fcbd806f..b5225fd7 100644 --- a/posthog_flutter/lib/src/posthog_config.dart +++ b/posthog_flutter/lib/src/posthog_config.dart @@ -264,8 +264,24 @@ class PostHogConfig { /// Callback to be invoked when feature flags are loaded. /// - /// Use [Posthog.getFeatureFlag] or [Posthog.isFeatureEnabled] within this - /// callback to access the loaded flag values. + /// It receives the enabled flags and whether loading failed. It runs after + /// the flags load at startup, after [Posthog.reloadFeatureFlags], and after + /// calls that reload flags such as `identify()` or `group()`. When a load + /// fails, [PostHogFeatureFlagsLoaded.errorsLoading] is `true` and the flags + /// are the last known values. Defaults to `null`. + /// + /// **Android:** not called after [Posthog.reloadFeatureFlags]. Use the + /// flags that call returns instead. + /// + /// **Example:** + /// ```dart + /// final config = PostHogConfig('') + /// ..onFeatureFlags = (loaded) { + /// if (loaded.variants['new-checkout'] == 'test') { + /// // Show the test variant. + /// } + /// }; + /// ``` OnFeatureFlagsCallback? onFeatureFlags; /// Whether to automatically register this device's push token with PostHog. diff --git a/posthog_flutter/lib/src/posthog_flutter_desktop.dart b/posthog_flutter/lib/src/posthog_flutter_desktop.dart index cf883e54..919d6064 100644 --- a/posthog_flutter/lib/src/posthog_flutter_desktop.dart +++ b/posthog_flutter/lib/src/posthog_flutter_desktop.dart @@ -6,6 +6,7 @@ import 'core/file_storage.dart'; import 'core/utils/utils.dart'; import 'error_tracking/dart_exception_processor.dart'; import 'feature_flag_result.dart'; +import 'feature_flags_loaded.dart'; import 'logs/posthog_log_severity.dart'; import 'posthog_config.dart'; import 'posthog_constants.dart'; @@ -175,8 +176,8 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { projectToken: config.projectToken, host: removeTrailingSlash(config.host), ); - _featureFlagsUnsubscribe = - client.onFeatureFlags(() => _config?.onFeatureFlags?.call()); + _featureFlagsUnsubscribe = client.onFeatureFlags( + () => _config?.onFeatureFlags?.call(client.featureFlagsLoaded())); final stepsConfig = config.errorTrackingConfig.exceptionSteps; _exceptionSteps = stepsConfig.enabled @@ -451,8 +452,13 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { false); @override - Future reloadFeatureFlags() => _guard( - 'reloadFeatureFlags', (client) => client.reloadFeatureFlagsAsync()); + Future reloadFeatureFlags() => _guardWith( + 'reloadFeatureFlags', + PostHogFeatureFlagsLoaded(variants: const {}, errorsLoading: true), + (client) async { + await client.reloadFeatureFlagsAsync(); + return client.featureFlagsLoaded(); + }); @override Future setPersonPropertiesForFlags( diff --git a/posthog_flutter/lib/src/posthog_flutter_io.dart b/posthog_flutter/lib/src/posthog_flutter_io.dart index c69deb2c..1ee6368c 100644 --- a/posthog_flutter/lib/src/posthog_flutter_io.dart +++ b/posthog_flutter/lib/src/posthog_flutter_io.dart @@ -17,6 +17,7 @@ import 'utils/flutter_version.dart'; import 'utils/property_normalizer.dart'; import 'feature_flag_result.dart'; +import 'feature_flags_loaded.dart'; import 'logs/posthog_log_severity.dart'; import 'posthog_config.dart'; import 'posthog_constants.dart'; @@ -73,7 +74,8 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { await cleanupSurveys(); return null; case 'onFeatureFlagsCallback': - _onFeatureFlagsCallback?.call(); + _onFeatureFlagsCallback + ?.call(featureFlagsLoadedFromMap(call.arguments)); break; case 'pushIdentityProvider': // Returning a value here is the reply the native SDK awaits. @@ -585,15 +587,17 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } @override - Future reloadFeatureFlags() async { + Future reloadFeatureFlags() async { if (!isSupportedPlatform()) { - return; + return featureFlagsLoadedFromMap(null); } try { - await _methodChannel.invokeMethod('reloadFeatureFlags'); + return featureFlagsLoadedFromMap( + await _methodChannel.invokeMethod('reloadFeatureFlags')); } on PlatformException catch (exception) { printIfDebug('Exeption on reloadFeatureFlags: $exception'); + return featureFlagsLoadedFromMap(null); } } diff --git a/posthog_flutter/lib/src/posthog_flutter_platform_interface.dart b/posthog_flutter/lib/src/posthog_flutter_platform_interface.dart index a775d5f7..5643fd6c 100644 --- a/posthog_flutter/lib/src/posthog_flutter_platform_interface.dart +++ b/posthog_flutter/lib/src/posthog_flutter_platform_interface.dart @@ -1,15 +1,17 @@ import 'package:plugin_platform_interface/plugin_platform_interface.dart'; import 'feature_flag_result.dart'; +import 'feature_flags_loaded.dart'; import 'logs/posthog_log_severity.dart'; import 'posthog_config.dart'; import 'posthog_flutter_io.dart'; /// Defines the callback signature for when feature flags are loaded. /// -/// Use [Posthog.getFeatureFlag] or [Posthog.isFeatureEnabled] within this -/// callback to access the loaded flag values. -typedef OnFeatureFlagsCallback = void Function(); +/// The callback receives the enabled flags and whether loading failed. See +/// [PostHogFeatureFlagsLoaded]. +typedef OnFeatureFlagsCallback = void Function( + PostHogFeatureFlagsLoaded loaded); abstract class PosthogFlutterPlatformInterface extends PlatformInterface { /// Constructs a PosthogFlutterPlatform. @@ -173,7 +175,7 @@ abstract class PosthogFlutterPlatformInterface extends PlatformInterface { throw UnimplementedError('isFeatureEnabled() has not been implemented.'); } - Future reloadFeatureFlags() { + Future reloadFeatureFlags() { throw UnimplementedError('reloadFeatureFlags() has not been implemented.'); } diff --git a/posthog_flutter/lib/src/posthog_flutter_web_handler.dart b/posthog_flutter/lib/src/posthog_flutter_web_handler.dart index d35dd08d..0eb44ccd 100644 --- a/posthog_flutter/lib/src/posthog_flutter_web_handler.dart +++ b/posthog_flutter/lib/src/posthog_flutter_web_handler.dart @@ -1,6 +1,9 @@ // ignore_for_file: avoid_dynamic_calls, avoid_annotating_with_dynamic +import 'dart:async'; import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; +import 'dart:math' show max; import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:flutter/services.dart'; @@ -64,7 +67,7 @@ extension PostHogExtension on PostHog { external void unregister(JSAny key); // ignore: non_constant_identifier_names external JSAny? get_session_id(); - external void onFeatureFlags(JSFunction callback); + external JSFunction onFeatureFlags(JSFunction callback); external void startSessionRecording(); external void stopSessionRecording(); external bool sessionRecordingStarted(); @@ -72,10 +75,20 @@ extension PostHogExtension on PostHog { external void set_config(JSAny config); external JSObject? get config; external SessionManager? get sessionManager; + external FeatureFlags? get featureFlags; // ignore: non_constant_identifier_names external void _overrideSDKInfo(JSAny sdkName, JSAny sdkVersion); } +// Feature flags JS interop +@JS() +@staticInterop +class FeatureFlags {} + +extension FeatureFlagsExtension on FeatureFlags { + external JSObject getFlagVariants(); +} + // SessionManager JS interop @JS() @staticInterop @@ -200,6 +213,80 @@ String _exceptionMessage(Map properties) { return 'Exception'; } +/// posthog-js's pause between `/flags` retries. +const _flagsRetryDelayMs = 500; + +/// Reloads feature flags and completes with the flags that reload loaded. +/// +/// posthog-js 1.438.2+ has `reloadFeatureFlagsAsync()`, which settles after +/// this reload's own request, even when another request is already in flight. +/// Anything but a successful load reports the current flags with +/// `errorsLoading` set. +/// +/// Older posthog-js completes with the next flags posthog-js reports. It skips +/// the request without calling back when flags are disabled (completes right +/// away), and while reloading is paused or after repeated network failures +/// (completes once every request attempt posthog-js may make, including its +/// retries, would have timed out, plus one more request timeout). +Future> _reloadFeatureFlags(PostHog? ph) async { + final featureFlags = ph?.featureFlags; + if (ph == null || featureFlags == null) { + return {'variants': null, 'errorsLoading': true}; + } + Map currentFlags({required bool errorsLoading}) => { + 'variants': featureFlags.getFlagVariants().dartify(), + 'errorsLoading': errorsLoading + }; + + final flagsObject = featureFlags as JSObject; + if (flagsObject.has('reloadFeatureFlagsAsync')) { + final result = await flagsObject + .callMethod>('reloadFeatureFlagsAsync'.toJS) + .toDart; + final status = result?.getProperty('status'.toJS)?.toDart; + return currentFlags(errorsLoading: status != 'loaded'); + } + + final config = ph.config; + if (config + ?.getProperty('advanced_disable_feature_flags'.toJS) + ?.dartify() == + true) { + return currentFlags(errorsLoading: true); + } + + final reloaded = Completer>(); + var subscribed = false; + final unsubscribe = ph + .onFeatureFlags((JSArray flags, JSObject variants, [JSObject? context]) { + // onFeatureFlags replays the current flags synchronously: skip them. + if (!subscribed || reloaded.isCompleted) return; + reloaded.complete({ + 'variants': variants.dartify(), + 'errorsLoading': (context?.dartify() as Map?)?['errorsLoading'] == true, + }); + }.toJS); + subscribed = true; + ph.reloadFeatureFlags(); + + int? configInt(String key) => + (config?.getProperty(key.toJS)?.dartify() as num?)?.toInt(); + final requestTimeoutMs = configInt('feature_flag_request_timeout_ms') ?? 3000; + // posthog-js retries a timed-out /flags request within the same reload, + // 500 ms apart, so a successful retry can land well after one timeout. + final retries = max(0, configInt('feature_flag_request_max_retries') ?? 1); + try { + return await reloaded.future.timeout( + Duration( + milliseconds: + (retries + 2) * requestTimeoutMs + retries * _flagsRetryDelayMs), + onTimeout: () => currentFlags(errorsLoading: true), + ); + } finally { + unsubscribe.callAsFunction(); + } +} + Future handleWebMethodCall(MethodCall call) async { _maybeOverrideSDKInfo(); @@ -334,8 +421,7 @@ Future handleWebMethodCall(MethodCall call) async { ); break; case 'reloadFeatureFlags': - posthog?.reloadFeatureFlags(); - break; + return _reloadFeatureFlags(posthog); case 'setPersonPropertiesForFlags': final userProperties = safeMapConversion(args['userProperties']); // reload is orchestrated by the Dart layer, so disable it here. diff --git a/posthog_flutter/lib/src/utils/channel_serialization.dart b/posthog_flutter/lib/src/utils/channel_serialization.dart index 8fc7f2ec..c1bd9ebc 100644 --- a/posthog_flutter/lib/src/utils/channel_serialization.dart +++ b/posthog_flutter/lib/src/utils/channel_serialization.dart @@ -1,4 +1,5 @@ import '../feature_flag_result.dart'; +import '../feature_flags_loaded.dart'; import '../posthog_config.dart'; import '../util/logging.dart'; @@ -170,3 +171,18 @@ PostHogFeatureFlagResult? featureFlagResultFromMap( payload: result['payload'], ); } + +/// Reads a `{variants, errorsLoading}` map sent over the platform channel. +/// A missing or malformed map reads as empty flags with `errorsLoading` set. +PostHogFeatureFlagsLoaded featureFlagsLoadedFromMap(Object? map) { + if (map is! Map) { + return PostHogFeatureFlagsLoaded(variants: const {}, errorsLoading: true); + } + final variants = map['variants']; + return PostHogFeatureFlagsLoaded( + variants: variants is Map + ? {for (final e in variants.entries) '${e.key}': e.value} + : const {}, + errorsLoading: map['errorsLoading'] as bool? ?? false, + ); +} diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index 36517dd8..6a532057 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -5,6 +5,7 @@ import 'dart:ui'; import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/src/error_tracking/posthog_error_tracking_autocapture_integration.dart'; import 'package:posthog_flutter/src/feature_flag_result.dart'; +import 'package:posthog_flutter/src/feature_flags_loaded.dart'; import 'package:posthog_flutter/src/posthog_config.dart'; import 'package:posthog_flutter/src/posthog_desktop_app_info.dart'; import 'package:posthog_flutter/src/posthog_flutter_desktop.dart'; @@ -609,7 +610,7 @@ void main() { final flagsLoaded = Completer(); final seenEventNames = []; final config = configFor(server) - ..onFeatureFlags = () { + ..onFeatureFlags = (_) { if (!flagsLoaded.isCompleted) flagsLoaded.complete(); } ..beforeSend = [ @@ -1728,7 +1729,7 @@ void main() { server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); final flagsLoaded = Completer(); final config = configFor(server) - ..onFeatureFlags = () { + ..onFeatureFlags = (_) { if (!flagsLoaded.isCompleted) flagsLoaded.complete(); }; final platform = await setUpPlatform(config); @@ -1758,7 +1759,7 @@ void main() { server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); final flagsLoaded = Completer(); final config = configFor(server) - ..onFeatureFlags = () { + ..onFeatureFlags = (_) { if (!flagsLoaded.isCompleted) flagsLoaded.complete(); }; final platform = await setUpPlatform(config); @@ -1794,7 +1795,7 @@ void main() { server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); final flagsLoaded = Completer(); final config = configFor(server) - ..onFeatureFlags = () { + ..onFeatureFlags = (_) { if (!flagsLoaded.isCompleted) flagsLoaded.complete(); }; final platform = await setUpPlatform(config); @@ -1813,7 +1814,7 @@ void main() { server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); final flagsLoaded = Completer(); final config = configFor(server) - ..onFeatureFlags = () { + ..onFeatureFlags = (_) { if (!flagsLoaded.isCompleted) flagsLoaded.complete(); }; final platform = await setUpPlatform(config); @@ -1840,7 +1841,7 @@ void main() { final flagsLoaded = Completer(); final config = configFor(server) ..sendFeatureFlagEvent = false - ..onFeatureFlags = () { + ..onFeatureFlags = (_) { if (!flagsLoaded.isCompleted) flagsLoaded.complete(); }; final platform = await setUpPlatform(config); @@ -1899,7 +1900,7 @@ void main() { final flagsLoaded = Completer(); final config = configFor(server) ..sendFeatureFlagEvent = false - ..onFeatureFlags = () { + ..onFeatureFlags = (_) { if (!flagsLoaded.isCompleted) flagsLoaded.complete(); }; final platform = await setUpPlatform(config); @@ -1918,6 +1919,27 @@ void main() { expect(_propertiesOf(called)[r'$feature_flag'], 'variant-flag'); }); + test( + 'reloadFeatureFlags returns the loaded flags, and the last known ones when loading fails', + () async { + final server = await LocalPostHogServer.start(); + server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); + final announced = []; + final platform = await setUpPlatform(configFor(server) + ..preloadFeatureFlags = false + ..onFeatureFlags = announced.add); + + final loaded = await platform.reloadFeatureFlags(); + expect(loaded.variants, {'variant-flag': 'test-variant'}); + expect(loaded.errorsLoading, isFalse); + + server.respond = (_) => const PostHogResponse(HttpStatus.badRequest); + final failed = await platform.reloadFeatureFlags(); + expect(failed.variants, {'variant-flag': 'test-variant'}); + expect(failed.errorsLoading, isTrue); + expect(announced.last.errorsLoading, isTrue); + }); + test('onFeatureFlags reports the flags cached by the previous launch', () async { final server = await LocalPostHogServer.start(); @@ -1934,7 +1956,7 @@ void main() { final announced = Completer(); await platform.setup(configFor(server) ..preloadFeatureFlags = false - ..onFeatureFlags = () { + ..onFeatureFlags = (_) { payloads.add(platform .getFeatureFlagResult( key: 'variant-flag', sendFeatureFlagEvent: false) diff --git a/posthog_flutter/test/posthog_flutter_io_test.dart b/posthog_flutter/test/posthog_flutter_io_test.dart index 76d1f39c..33a514fa 100644 --- a/posthog_flutter/test/posthog_flutter_io_test.dart +++ b/posthog_flutter/test/posthog_flutter_io_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/src/feature_flag_result.dart'; import 'package:posthog_flutter/src/logs/posthog_log_severity.dart'; +import 'package:posthog_flutter/src/feature_flags_loaded.dart'; import 'package:posthog_flutter/src/posthog_config.dart'; import 'package:posthog_flutter/src/posthog_event.dart'; import 'package:posthog_flutter/src/posthog_flutter_io.dart'; @@ -162,7 +163,7 @@ void main() { 'setup initializes method call handler and registers callback if provided', () async { bool callbackInvoked = false; - void testCallback() { + void testCallback(PostHogFeatureFlagsLoaded _) { callbackInvoked = true; } @@ -212,94 +213,45 @@ void main() { }, ); - test( - 'invokes callback when native sends onFeatureFlagsCallback event', - () async { - bool callbackInvoked = false; - - void testCallback() { - callbackInvoked = true; - } - - testConfig = PostHogConfig( - 'test_project_token', - onFeatureFlags: testCallback, - ); - await posthogFlutterIO.setup(testConfig); - - // Native sends empty map (iOS/Android behavior) - final mockNativeArgs = {}; - - await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .handlePlatformMessage( - channel.name, - channel.codec.encodeMethodCall( - MethodCall('onFeatureFlagsCallback', mockNativeArgs), - ), - (ByteData? data) {}, - ); - - expect(callbackInvoked, isTrue); - }, - ); - - test( - 'invokes callback when native sends onFeatureFlagsCallback with empty map (mobile behavior)', - () async { - bool callbackInvoked = false; - - void testCallback() { - callbackInvoked = true; - } - - testConfig = PostHogConfig( - 'test_project_token', - onFeatureFlags: testCallback, - ); - await posthogFlutterIO.setup(testConfig); - - // Simulate mobile sending an empty map - final mockNativeArgs = {}; - - await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .handlePlatformMessage( - channel.name, - channel.codec.encodeMethodCall( - MethodCall('onFeatureFlagsCallback', mockNativeArgs), - ), - (ByteData? data) {}, - ); - - expect(callbackInvoked, isTrue); - }, - ); - - test('invokes callback even with malformed native args', () async { - bool callbackInvoked = false; + test('passes the native flags to the callback', () async { + final received = []; + await posthogFlutterIO.setup( + PostHogConfig('test_project_token', onFeatureFlags: received.add)); - void testCallback() { - callbackInvoked = true; - } + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(const MethodCall( + 'onFeatureFlagsCallback', + { + 'variants': {'on': true, 'variant': 'test', 'off': false}, + 'errorsLoading': true, + }, + )), + (ByteData? data) {}, + ); - testConfig = - PostHogConfig('test_project_token', onFeatureFlags: testCallback); - await posthogFlutterIO.setup(testConfig); + expect(received.single.variants, {'on': true, 'variant': 'test'}); + expect(received.single.flags, ['on', 'variant']); + expect(received.single.errorsLoading, isTrue); + }); - // Simulate native sending malformed arguments - callback should still be invoked - final mockNativeArgsMalformed = { - 'flags': 123, // Invalid type, but callback is void so it doesn't matter - }; + test('passes empty flags to the callback for malformed native args', + () async { + final received = []; + await posthogFlutterIO.setup( + PostHogConfig('test_project_token', onFeatureFlags: received.add)); await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .handlePlatformMessage( channel.name, channel.codec.encodeMethodCall( - MethodCall('onFeatureFlagsCallback', mockNativeArgsMalformed), + const MethodCall('onFeatureFlagsCallback', {'variants': 123}), ), (ByteData? data) {}, ); - expect(callbackInvoked, isTrue); + expect(received.single.variants, isEmpty); }); test('does not invoke callback when no callback is registered', () async { diff --git a/posthog_flutter/test/posthog_flutter_platform_interface_fake.dart b/posthog_flutter/test/posthog_flutter_platform_interface_fake.dart index b71a0bfe..fbffbc3c 100644 --- a/posthog_flutter/test/posthog_flutter_platform_interface_fake.dart +++ b/posthog_flutter/test/posthog_flutter_platform_interface_fake.dart @@ -1,4 +1,5 @@ import 'package:posthog_flutter/src/feature_flag_result.dart'; +import 'package:posthog_flutter/src/feature_flags_loaded.dart'; import 'package:posthog_flutter/src/logs/posthog_log_severity.dart'; import 'package:posthog_flutter/src/posthog_config.dart'; import 'package:posthog_flutter/src/posthog_flutter_platform_interface.dart'; @@ -128,8 +129,9 @@ class PosthogFlutterPlatformFake extends PosthogFlutterPlatformInterface { Future stopSessionRecording() async {} @override - Future reloadFeatureFlags() async { + Future reloadFeatureFlags() async { reloadFeatureFlagsCount++; + return PostHogFeatureFlagsLoaded(variants: const {}, errorsLoading: false); } @override diff --git a/posthog_flutter/test/posthog_flutter_web_handler_test.dart b/posthog_flutter/test/posthog_flutter_web_handler_test.dart index 35ab74c0..97c48b9f 100644 --- a/posthog_flutter/test/posthog_flutter_web_handler_test.dart +++ b/posthog_flutter/test/posthog_flutter_web_handler_test.dart @@ -80,6 +80,152 @@ void main() { }); }); + group('handleWebMethodCall reloadFeatureFlags', () { + late JSFunction? listener; + late int reloads; + late int unsubscribes; + + /// Installs a fake posthog-js whose reload answers with [answer], if any. + void installFake({ + Map config = const {}, + ({Map variants, bool errorsLoading})? answer, + Duration answerAfter = Duration.zero, + String? asyncStatus, + }) { + listener = null; + reloads = 0; + unsubscribes = 0; + var current = {'cached': true}.jsify()!; + final featureFlags = JSObject() + ..setProperty('getFlagVariants'.toJS, (() => current).toJS); + if (asyncStatus != null) { + featureFlags.setProperty( + 'reloadFeatureFlagsAsync'.toJS, + (() => Future(() { + current = {'fresh': 'v2'}.jsify()!; + return {'status': asyncStatus}.jsify(); + }).toJS).toJS, + ); + } + final fake = JSObject() + ..setProperty('featureFlags'.toJS, featureFlags) + ..setProperty('config'.toJS, config.jsify()) + ..setProperty( + 'onFeatureFlags'.toJS, + ((JSFunction callback) { + listener = callback; + callback.callAsFunction(null, [].jsify(), current); + return (() => unsubscribes++).toJS; + }).toJS, + ) + ..setProperty( + 'reloadFeatureFlags'.toJS, + (() { + reloads++; + if (answer == null) return; + Future.delayed( + answerAfter, + () => listener?.callAsFunction( + null, + answer.variants.keys.toList().jsify(), + answer.variants.jsify(), + {'errorsLoading': answer.errorsLoading}.jsify(), + )); + }).toJS, + ); + globalContext.setProperty('posthog'.toJS, fake); + } + + test('completes with the flags the reload delivers, not the replayed ones', + () async { + installFake(answer: (variants: {'fresh': 'v2'}, errorsLoading: true)); + + final result = + await handleWebMethodCall(const MethodCall('reloadFeatureFlags')) + as Map; + + expect(result, { + 'variants': {'fresh': 'v2'}, + 'errorsLoading': true, + }); + expect(unsubscribes, 1); + }); + + test('awaits reloadFeatureFlagsAsync when posthog-js has it', () async { + installFake(asyncStatus: 'loaded'); + + final result = + await handleWebMethodCall(const MethodCall('reloadFeatureFlags')) + as Map; + + expect(result, { + 'variants': {'fresh': 'v2'}, + 'errorsLoading': false, + }); + expect(reloads, 0); + }); + + test('reports an error when reloadFeatureFlagsAsync skips the request', + () async { + installFake(asyncStatus: 'skipped'); + + final result = + await handleWebMethodCall(const MethodCall('reloadFeatureFlags')) + as Map; + + expect(result['errorsLoading'], isTrue); + }); + + test('completes right away with an error when flags are disabled', + () async { + installFake(config: {'advanced_disable_feature_flags': true}); + + final result = + await handleWebMethodCall(const MethodCall('reloadFeatureFlags')) + as Map; + + expect(result, { + 'variants': {'cached': true}, + 'errorsLoading': true, + }); + expect(reloads, 0); + }); + + test('waits for a reload that posthog-js retries after a timeout', + () async { + // Past twice the request timeout, but within one retry after 500 ms. + installFake( + config: {'feature_flag_request_timeout_ms': 20}, + answer: (variants: {'fresh': 'v2'}, errorsLoading: false), + answerAfter: const Duration(milliseconds: 300), + ); + + final result = + await handleWebMethodCall(const MethodCall('reloadFeatureFlags')) + as Map; + + expect(result, { + 'variants': {'fresh': 'v2'}, + 'errorsLoading': false, + }); + }); + + test('completes with an error when posthog-js never calls back', () async { + installFake(config: {'feature_flag_request_timeout_ms': 5}); + + final result = + await handleWebMethodCall(const MethodCall('reloadFeatureFlags')) + as Map; + + expect(result, { + 'variants': {'cached': true}, + 'errorsLoading': true, + }); + expect(reloads, 1); + expect(unsubscribes, 1); + }); + }); + group('handleWebMethodCall addExceptionStep', () { String? capturedMessage; JSAny? capturedProperties; diff --git a/posthog_flutter/test/posthog_test.dart b/posthog_flutter/test/posthog_test.dart index a3d9f7ad..7b229e2a 100644 --- a/posthog_flutter/test/posthog_test.dart +++ b/posthog_flutter/test/posthog_test.dart @@ -27,7 +27,7 @@ void main() { test( 'setup passes config and onFeatureFlags callback to platform interface', () async { - void testCallback() {} + void testCallback(PostHogFeatureFlagsLoaded _) {} final config = PostHogConfig( 'test_project_token', diff --git a/pubspec.lock b/pubspec.lock index 97a86f5c..1bd0cbe4 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -246,10 +246,10 @@ packages: dependency: transitive description: name: google_maps_flutter - sha256: fc084ddabc22645bebbe9df51baeec4949e5d14fc37492dce80501863425a82e + sha256: "392492775b6928b7419bd1d04c7562dab73a543e71078088a40ae7324acd3641" url: "https://pub.dev" source: hosted - version: "2.19.0" + version: "2.18.1" google_maps_flutter_android: dependency: transitive description: @@ -347,10 +347,10 @@ packages: dependency: transitive description: name: matcher - sha256: "31bd099b47c10cd1aeb55146a2d46ce0277630ecef3f7dae54ad7873f36696cd" + sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 url: "https://pub.dev" source: hosted - version: "0.12.20" + version: "0.12.19" material_color_utilities: dependency: transitive description: @@ -363,10 +363,10 @@ packages: dependency: transitive description: name: meta - sha256: "307249ce4ff29d58a18e97f6345f539382eb9c9c29ecda628900f31de0443dd9" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.dev" source: hosted - version: "1.19.0" + version: "1.18.0" package_config: dependency: transitive description: @@ -520,10 +520,10 @@ packages: dependency: transitive description: name: stack_trace - sha256: "277654b3034d17ac6f9f1cb5595db011b1d5d41e8806866db28e0abaa101c490" + sha256: "8b27215b45d22309b5cddda1aa2b19bdfec9df0e765f2de506401c071d38d1b1" url: "https://pub.dev" source: hosted - version: "1.12.2" + version: "1.12.1" stream_channel: dependency: transitive description: @@ -576,10 +576,10 @@ packages: dependency: transitive description: name: test_api - sha256: "2a122cbe059f8b610d3a5415f42e255b6c17b1f21eee1d960f31080237fb4f11" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.dev" source: hosted - version: "0.7.12" + version: "0.7.11" tuple: dependency: transitive description: @@ -608,10 +608,10 @@ packages: dependency: transitive description: name: vector_math - sha256: f36f9f3be64c6198714492bb455c11056e33e2f85d9a0b676a48301e44fdcf47 + sha256: d530bd74fea330e6e364cda7a85019c434070188383e1cd8d9777ee586914c5b url: "https://pub.dev" source: hosted - version: "2.4.2" + version: "2.2.0" vm_service: dependency: transitive description: @@ -701,5 +701,5 @@ packages: source: hosted version: "3.1.3" sdks: - dart: ">=3.13.0 <4.0.0" - flutter: ">=3.47.0" + dart: ">=3.12.0 <4.0.0" + flutter: ">=3.44.0" From 7eebbca2fc72c233b497a4020fc87f75ddfa59d7 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 29 Oct 2026 12:02:21 -0400 Subject: [PATCH 05/15] docs(changeset): fold the posthog-android 4.0 and flags-loaded entries into one changeset Co-Authored-By: Claude Opus 5.5 --- .changeset/v6-flags-loaded-result.md | 6 ------ .changeset/v6-posthog-android-4.md | 5 ----- .changeset/v6-posthog-ios-4.md | 3 +++ 3 files changed, 3 insertions(+), 11 deletions(-) delete mode 100644 .changeset/v6-flags-loaded-result.md delete mode 100644 .changeset/v6-posthog-android-4.md diff --git a/.changeset/v6-flags-loaded-result.md b/.changeset/v6-flags-loaded-result.md deleted file mode 100644 index 03501e51..00000000 --- a/.changeset/v6-flags-loaded-result.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -"posthog_flutter": major ---- - -- **Breaking:** `PostHogConfig.onFeatureFlags` now receives a `PostHogFeatureFlagsLoaded` with the enabled flags, their values and whether loading failed. Update callbacks from `() { ... }` to `(loaded) { ... }` (or `(_) { ... }`) -- **Breaking:** `reloadFeatureFlags()` now returns a `Future` with the reloaded flags diff --git a/.changeset/v6-posthog-android-4.md b/.changeset/v6-posthog-android-4.md deleted file mode 100644 index 3ec91401..00000000 --- a/.changeset/v6-posthog-android-4.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"posthog_flutter": major ---- - -- **Breaking:** adopt `posthog-android` 4.x diff --git a/.changeset/v6-posthog-ios-4.md b/.changeset/v6-posthog-ios-4.md index 39b171fa..83f7019f 100644 --- a/.changeset/v6-posthog-ios-4.md +++ b/.changeset/v6-posthog-ios-4.md @@ -3,6 +3,9 @@ --- - **Breaking:** adopt `posthog-ios` 4.x, which requires Xcode 26 +- **Breaking:** adopt `posthog-android` 4.x +- **Breaking:** pass a `PostHogFeatureFlagsLoaded` with the enabled flags, their values and whether loading failed to `PostHogConfig.onFeatureFlags` — update callbacks from `() { ... }` to `(loaded) { ... }` or `(_) { ... }` +- **Breaking:** return the reloaded flags from `reloadFeatureFlags()` as a `Future` - **Breaking:** return `null` instead of the raw string for a malformed, empty, or whitespace-only `getFeatureFlagResult()` payload on iOS and macOS - **Breaking:** keep string payloads passed to `PostHogBootstrapConfig.featureFlagPayloads`, such as `"123"`, as strings on iOS and macOS instead of decoding them to numbers or booleans - **Breaking:** stop sending the legacy `version` and `build` properties on `Application Installed`, `Application Updated` and `Application Opened` on iOS and macOS — use `$app_version` and `$app_build`. Android still sends them until `posthog-android` drops them From 4220eb02555b1378eaaf9f567c75866be24475c2 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Sun, 25 Oct 2026 12:45:37 -0400 Subject: [PATCH 06/15] feat: add an onFeatureFlags listener you can unsubscribe --- .changeset/v6-on-feature-flags-listener.md | 5 ++ posthog_flutter/lib/posthog_flutter_web.dart | 18 ++-- .../lib/src/feature_flags_listeners.dart | 45 ++++++++++ .../lib/src/feature_flags_loaded.dart | 17 ++++ posthog_flutter/lib/src/posthog.dart | 35 ++++++++ .../lib/src/posthog_flutter_desktop.dart | 8 +- .../lib/src/posthog_flutter_io.dart | 6 +- .../test/feature_flags_listeners_test.dart | 83 +++++++++++++++++++ .../test/posthog_flutter_desktop_test.dart | 15 ++++ .../test/posthog_flutter_io_test.dart | 24 ++++++ .../test/posthog_flutter_web_setup_test.dart | 31 +++++++ posthog_flutter/test/posthog_test.dart | 20 +++++ 12 files changed, 298 insertions(+), 9 deletions(-) create mode 100644 .changeset/v6-on-feature-flags-listener.md create mode 100644 posthog_flutter/lib/src/feature_flags_listeners.dart create mode 100644 posthog_flutter/test/feature_flags_listeners_test.dart diff --git a/.changeset/v6-on-feature-flags-listener.md b/.changeset/v6-on-feature-flags-listener.md new file mode 100644 index 00000000..44d2f2e0 --- /dev/null +++ b/.changeset/v6-on-feature-flags-listener.md @@ -0,0 +1,5 @@ +--- +"posthog_flutter": minor +--- + +- Add `Posthog().onFeatureFlags()` to get notified after every feature flags load, returning a `PostHogFeatureFlagsSubscription` you can `unsubscribe()` diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index 3f09dbfb..cc6fe120 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -11,6 +11,7 @@ import 'package:posthog_flutter/src/utils/channel_serialization.dart'; import 'package:posthog_flutter/src/utils/property_normalizer.dart'; import 'src/feature_flag_result.dart'; +import 'src/feature_flags_listeners.dart'; import 'src/feature_flags_loaded.dart'; import 'src/logs/posthog_log_severity.dart'; import 'src/posthog_config.dart'; @@ -34,6 +35,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { /// Copied at setup. Event beforeSend runs here, before posthog-js, the same /// way [PosthogFlutterIO] runs it before the platform channel. List _beforeSendCallbacks = []; + JSFunction? _featureFlagsUnsubscribe; // posthog-js has no current screen, so the feature events get it from here // the way native SDKs stamp it: the last screen beforeSend accepted, under @@ -126,19 +128,23 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { WebCanvasMaskProvider(config).register(); - if (config.onFeatureFlags != null && ph != null) { - final dartCallback = config.onFeatureFlags!; + if (ph != null) { + final dartCallback = config.onFeatureFlags; // JS SDK calls with: (flags: string[], variants: Record, context?: {errorsLoading}) final jsCallback = (JSArray jsFlags, JSObject jsFlagVariants, [JSObject? jsContext]) { - dartCallback(featureFlagsLoadedFromMap({ + final loaded = featureFlagsLoadedFromMap({ 'variants': jsFlagVariants.dartify(), 'errorsLoading': (jsContext?.dartify() as Map?)?['errorsLoading'], - })); + }); + dartCallback?.call(loaded); + FeatureFlagsListeners.instance.deliver(loaded); }.toJS; - ph.onFeatureFlags(jsCallback); + // Repeated setup would otherwise deliver each load twice. + _featureFlagsUnsubscribe?.callAsFunction(); + _featureFlagsUnsubscribe = ph.onFeatureFlags(jsCallback); } } @@ -502,6 +508,8 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { @override Future close() async { _currentScreen = null; + _featureFlagsUnsubscribe?.callAsFunction(); + _featureFlagsUnsubscribe = null; return handleWebMethodCall(const MethodCall('close')); } diff --git a/posthog_flutter/lib/src/feature_flags_listeners.dart b/posthog_flutter/lib/src/feature_flags_listeners.dart new file mode 100644 index 00000000..9bfbf0ae --- /dev/null +++ b/posthog_flutter/lib/src/feature_flags_listeners.dart @@ -0,0 +1,45 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; + +import 'feature_flags_loaded.dart'; +import 'posthog_flutter_platform_interface.dart'; + +/// Delivers each feature flags load to the listeners added with +/// [Posthog.onFeatureFlags]. +@internal +class FeatureFlagsListeners { + FeatureFlagsListeners._(); + + static final instance = FeatureFlagsListeners._(); + + final _listeners = {}; + PostHogFeatureFlagsLoaded? _last; + + PostHogFeatureFlagsSubscription add(OnFeatureFlagsCallback listener) { + final token = Object(); + _listeners[token] = listener; + final current = _last; + if (current != null) { + // Skipped if a newer load already reached the listener. + scheduleMicrotask(() { + if (_listeners.containsKey(token) && identical(_last, current)) { + listener(current); + } + }); + } + return PostHogFeatureFlagsSubscription(() => _listeners.remove(token)); + } + + void deliver(PostHogFeatureFlagsLoaded loaded) { + _last = loaded; + for (final entry in _listeners.entries.toList()) { + // An earlier listener may have unsubscribed this one. + if (_listeners.containsKey(entry.key)) entry.value(loaded); + } + } + + /// Forgets the last load, so new listeners don't get the previous user's + /// flags after `reset()` or `close()`. + void clear() => _last = null; +} diff --git a/posthog_flutter/lib/src/feature_flags_loaded.dart b/posthog_flutter/lib/src/feature_flags_loaded.dart index 0a8c196b..8efade70 100644 --- a/posthog_flutter/lib/src/feature_flags_loaded.dart +++ b/posthog_flutter/lib/src/feature_flags_loaded.dart @@ -1,3 +1,5 @@ +import 'package:meta/meta.dart'; + /// The feature flags passed to [PostHogConfig.onFeatureFlags] and returned by /// [Posthog.reloadFeatureFlags]. /// @@ -51,3 +53,18 @@ class PostHogFeatureFlagsLoaded { String toString() => 'PostHogFeatureFlagsLoaded(variants: $variants, errorsLoading: $errorsLoading)'; } + +/// A listener added with [Posthog.onFeatureFlags]. +/// +/// Call [unsubscribe] to stop receiving feature flags updates. +class PostHogFeatureFlagsSubscription { + final void Function() _unsubscribe; + + /// Not part of the public API. + @internal + PostHogFeatureFlagsSubscription(this._unsubscribe); + + /// Stops the listener from receiving further updates. Calling it again has + /// no effect. + void unsubscribe() => _unsubscribe(); +} diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index d45b5699..d08213a6 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -5,6 +5,7 @@ import 'package:meta/meta.dart'; import 'package:posthog_flutter/src/error_tracking/posthog_error_tracking_autocapture_integration.dart'; import 'package:posthog_flutter/src/error_tracking/posthog_exception.dart'; import 'feature_flag_result.dart'; +import 'feature_flags_listeners.dart'; import 'feature_flags_loaded.dart'; import 'logs/posthog_log_record.dart'; import 'logs/posthog_log_severity.dart'; @@ -389,6 +390,7 @@ class Posthog { // Bumped before the platform call because native rotates inside that round // trip. PostHogInternalEvents.requestReplaySessionReset(); + FeatureFlagsListeners.instance.clear(); await _posthog.reset(); } @@ -515,6 +517,38 @@ class Posthog { Future reloadFeatureFlags() => _posthog.reloadFeatureFlags(); + /// Registers a [callback] that's invoked whenever feature flags are loaded + /// or updated. + /// + /// It's invoked after the SDK loads flags at startup (including from + /// bootstrap values), after [reloadFeatureFlags], and after calls that + /// reload flags such as [identify], [group] or [reset]. If a load fails, + /// [PostHogFeatureFlagsLoaded.errorsLoading] is `true` and the values are + /// the last known flags. If flags have already loaded when you register, + /// it's also invoked with the current values shortly afterwards. + /// + /// You can register before [setup], and the callback stays registered + /// across [close] and [setup]. Treat each invocation as "flags may have + /// changed", not as an exactly-once event. + /// + /// Returns a subscription; call + /// [PostHogFeatureFlagsSubscription.unsubscribe] to stop receiving updates. + /// + /// **Example:** + /// ```dart + /// final subscription = Posthog().onFeatureFlags((loaded) { + /// if (loaded.variants['new-checkout'] == 'test') { + /// // Show the test variant. + /// } + /// }); + /// + /// // Later, stop listening. + /// subscription.unsubscribe(); + /// ``` + PostHogFeatureFlagsSubscription onFeatureFlags( + OnFeatureFlagsCallback callback) => + FeatureFlagsListeners.instance.add(callback); + /// Sets person properties that are used only for feature flag evaluation. /// /// Docs: https://posthog.com/docs/feature-flags @@ -1032,6 +1066,7 @@ class Posthog { // recording that follows must start clean either way. PostHogInternalEvents.requestReplaySessionReset(); PosthogObserver.clearCurrentContext(); + FeatureFlagsListeners.instance.clear(); // Uninstall Flutter integrations _uninstallFlutterIntegrations(); diff --git a/posthog_flutter/lib/src/posthog_flutter_desktop.dart b/posthog_flutter/lib/src/posthog_flutter_desktop.dart index 919d6064..ba6ab89d 100644 --- a/posthog_flutter/lib/src/posthog_flutter_desktop.dart +++ b/posthog_flutter/lib/src/posthog_flutter_desktop.dart @@ -6,6 +6,7 @@ import 'core/file_storage.dart'; import 'core/utils/utils.dart'; import 'error_tracking/dart_exception_processor.dart'; import 'feature_flag_result.dart'; +import 'feature_flags_listeners.dart'; import 'feature_flags_loaded.dart'; import 'logs/posthog_log_severity.dart'; import 'posthog_config.dart'; @@ -176,8 +177,11 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { projectToken: config.projectToken, host: removeTrailingSlash(config.host), ); - _featureFlagsUnsubscribe = client.onFeatureFlags( - () => _config?.onFeatureFlags?.call(client.featureFlagsLoaded())); + _featureFlagsUnsubscribe = client.onFeatureFlags(() { + final loaded = client.featureFlagsLoaded(); + _config?.onFeatureFlags?.call(loaded); + FeatureFlagsListeners.instance.deliver(loaded); + }); final stepsConfig = config.errorTrackingConfig.exceptionSteps; _exceptionSteps = stepsConfig.enabled diff --git a/posthog_flutter/lib/src/posthog_flutter_io.dart b/posthog_flutter/lib/src/posthog_flutter_io.dart index 1ee6368c..094b3e98 100644 --- a/posthog_flutter/lib/src/posthog_flutter_io.dart +++ b/posthog_flutter/lib/src/posthog_flutter_io.dart @@ -17,6 +17,7 @@ import 'utils/flutter_version.dart'; import 'utils/property_normalizer.dart'; import 'feature_flag_result.dart'; +import 'feature_flags_listeners.dart'; import 'feature_flags_loaded.dart'; import 'logs/posthog_log_severity.dart'; import 'posthog_config.dart'; @@ -74,8 +75,9 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { await cleanupSurveys(); return null; case 'onFeatureFlagsCallback': - _onFeatureFlagsCallback - ?.call(featureFlagsLoadedFromMap(call.arguments)); + final loaded = featureFlagsLoadedFromMap(call.arguments); + _onFeatureFlagsCallback?.call(loaded); + FeatureFlagsListeners.instance.deliver(loaded); break; case 'pushIdentityProvider': // Returning a value here is the reply the native SDK awaits. diff --git a/posthog_flutter/test/feature_flags_listeners_test.dart b/posthog_flutter/test/feature_flags_listeners_test.dart new file mode 100644 index 00000000..aa318590 --- /dev/null +++ b/posthog_flutter/test/feature_flags_listeners_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/feature_flags_listeners.dart'; +import 'package:posthog_flutter/src/feature_flags_loaded.dart'; + +PostHogFeatureFlagsLoaded loaded(String variant) => PostHogFeatureFlagsLoaded( + variants: {'flag': variant}, errorsLoading: false); + +void main() { + final listeners = FeatureFlagsListeners.instance; + final subscriptions = []; + + PostHogFeatureFlagsSubscription listen( + List received) { + final subscription = listeners.add(received.add); + subscriptions.add(subscription); + return subscription; + } + + tearDown(() { + for (final subscription in subscriptions) { + subscription.unsubscribe(); + } + subscriptions.clear(); + listeners.clear(); + }); + + test('delivers each load until unsubscribed', () { + final received = []; + final subscription = listen(received); + final first = loaded('a'); + + listeners.deliver(first); + subscription.unsubscribe(); + listeners.deliver(loaded('b')); + + expect(received, [first]); + }); + + test('replays the last load to a new listener once', () async { + final last = loaded('a'); + listeners.deliver(last); + final received = []; + listen(received); + + await pumpEventQueue(); + + expect(received, [last]); + }); + + test('skips the replay when a newer load arrives first', () async { + listeners.deliver(loaded('a')); + final received = []; + listen(received); + final newer = loaded('b'); + listeners.deliver(newer); + + await pumpEventQueue(); + + expect(received, [newer]); + }); + + test('does not replay after clear', () async { + listeners.deliver(loaded('a')); + listeners.clear(); + final received = []; + listen(received); + + await pumpEventQueue(); + + expect(received, isEmpty); + }); + + test('skips a listener unsubscribed by an earlier one during delivery', () { + late PostHogFeatureFlagsSubscription second; + subscriptions.add(listeners.add((_) => second.unsubscribe())); + final received = []; + second = listen(received); + + listeners.deliver(loaded('a')); + + expect(received, isEmpty); + }); +} diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index 6a532057..1c33e543 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -5,6 +5,7 @@ import 'dart:ui'; import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/src/error_tracking/posthog_error_tracking_autocapture_integration.dart'; import 'package:posthog_flutter/src/feature_flag_result.dart'; +import 'package:posthog_flutter/src/feature_flags_listeners.dart'; import 'package:posthog_flutter/src/feature_flags_loaded.dart'; import 'package:posthog_flutter/src/posthog_config.dart'; import 'package:posthog_flutter/src/posthog_desktop_app_info.dart'; @@ -1940,6 +1941,20 @@ void main() { expect(announced.last.errorsLoading, isTrue); }); + test('onFeatureFlags listeners receive each load', () async { + final server = await LocalPostHogServer.start(); + server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + final platform = + await setUpPlatform(configFor(server)..preloadFeatureFlags = false); + + await platform.reloadFeatureFlags(); + + expect(received.last.variants, {'variant-flag': 'test-variant'}); + }); + test('onFeatureFlags reports the flags cached by the previous launch', () async { final server = await LocalPostHogServer.start(); diff --git a/posthog_flutter/test/posthog_flutter_io_test.dart b/posthog_flutter/test/posthog_flutter_io_test.dart index 33a514fa..adc741b1 100644 --- a/posthog_flutter/test/posthog_flutter_io_test.dart +++ b/posthog_flutter/test/posthog_flutter_io_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/src/feature_flag_result.dart'; import 'package:posthog_flutter/src/logs/posthog_log_severity.dart'; +import 'package:posthog_flutter/src/feature_flags_listeners.dart'; import 'package:posthog_flutter/src/feature_flags_loaded.dart'; import 'package:posthog_flutter/src/posthog_config.dart'; import 'package:posthog_flutter/src/posthog_event.dart'; @@ -254,6 +255,29 @@ void main() { expect(received.single.variants, isEmpty); }); + test('passes the native flags to onFeatureFlags listeners', () async { + FeatureFlagsListeners.instance.clear(); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + await posthogFlutterIO.setup(PostHogConfig('test_project_token')); + + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(const MethodCall( + 'onFeatureFlagsCallback', + { + 'variants': {'on': true}, + 'errorsLoading': false, + }, + )), + (ByteData? data) {}, + ); + + expect(received.single.variants, {'on': true}); + }); + test('does not invoke callback when no callback is registered', () async { // Setup without callback testConfig = PostHogConfig('test_project_token'); diff --git a/posthog_flutter/test/posthog_flutter_web_setup_test.dart b/posthog_flutter/test/posthog_flutter_web_setup_test.dart index 718ad35b..fec61462 100644 --- a/posthog_flutter/test/posthog_flutter_web_setup_test.dart +++ b/posthog_flutter/test/posthog_flutter_web_setup_test.dart @@ -2,10 +2,13 @@ library; import 'dart:async'; +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/posthog_flutter.dart'; import 'package:posthog_flutter/posthog_flutter_web.dart'; +import 'package:posthog_flutter/src/feature_flags_listeners.dart'; import 'package:posthog_flutter/src/replay/web/web_canvas_mask_provider.dart'; /// posthog-js is initialized by the host app on web, so options the plugin @@ -63,4 +66,32 @@ void main() { expect(logs.where((line) => line.contains('evaluationContexts')), hasLength(1)); }); + + test('delivers posthog-js flag loads to onFeatureFlags listeners once', + () async { + final listeners = []; + globalContext.setProperty( + 'posthog'.toJS, + JSObject() + ..setProperty( + 'onFeatureFlags'.toJS, + ((JSFunction callback) { + listeners.add(callback); + return (() => listeners.remove(callback)).toJS; + }).toJS, + )); + addTearDown(() => globalContext.delete('posthog'.toJS)); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + final web = PosthogFlutterWeb(); + + await web.setup(PostHogConfig('test_project_token')); + await web.setup(PostHogConfig('test_project_token')); + for (final listener in listeners.toList()) { + listener.callAsFunction(null, ['on'].jsify(), {'on': true}.jsify(), null); + } + + expect(received.single.variants, {'on': true}); + }); } diff --git a/posthog_flutter/test/posthog_test.dart b/posthog_flutter/test/posthog_test.dart index 7b229e2a..7afa4a46 100644 --- a/posthog_flutter/test/posthog_test.dart +++ b/posthog_flutter/test/posthog_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/posthog_flutter.dart'; +import 'package:posthog_flutter/src/feature_flags_listeners.dart'; import 'package:posthog_flutter/src/posthog_flutter_platform_interface.dart'; import 'package:posthog_flutter/src/posthog_internal_events.dart'; import 'package:posthog_flutter/src/replay/mask/posthog_mask_controller.dart'; @@ -44,6 +45,25 @@ void main() { }, ); + for (final entry in Function()>{ + 'reset': () => Posthog().reset(), + 'close': () => Posthog().close(), + }.entries) { + test('onFeatureFlags does not replay flags loaded before ${entry.key}', + () async { + FeatureFlagsListeners.instance.deliver(PostHogFeatureFlagsLoaded( + variants: {'flag': true}, errorsLoading: false)); + await entry.value(); + final received = []; + final subscription = Posthog().onFeatureFlags(received.add); + addTearDown(subscription.unsubscribe); + + await pumpEventQueue(); + + expect(received, isEmpty); + }); + } + test('Windows and Linux support is enabled by default', () { expect(PostHogConfig('test_project_token').enableWindowsAndLinux, isTrue); }); From 0677b43a354826a2c11e67af6d2303314a7746a2 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Sun, 25 Oct 2026 12:57:06 -0400 Subject: [PATCH 07/15] fix: isolate failing flag listeners, skip web registration without onFeatureFlags, update API snapshot --- api/posthog_flutter.api.json | 52 +++++++++++++++++++ posthog_flutter/lib/posthog_flutter_web.dart | 4 +- .../lib/src/feature_flags_listeners.dart | 15 +++++- .../test/feature_flags_listeners_test.dart | 11 ++++ 4 files changed, 79 insertions(+), 3 deletions(-) diff --git a/api/posthog_flutter.api.json b/api/posthog_flutter.api.json index 71c718a4..8f110af3 100644 --- a/api/posthog_flutter.api.json +++ b/api/posthog_flutter.api.json @@ -4526,6 +4526,28 @@ "type": "method", "typeParameterNames": [] }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isStatic": false, + "name": "onFeatureFlags", + "parameters": [ + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": false, + "isRequired": true, + "name": "callback", + "relativePath": "lib/src/posthog.dart", + "typeName": "void Function(PostHogFeatureFlagsLoaded)" + } + ], + "relativePath": "lib/src/posthog.dart", + "returnTypeName": "PostHogFeatureFlagsSubscription", + "type": "method", + "typeParameterNames": [] + }, { "entryPoints": [], "isDeprecated": false, @@ -6109,6 +6131,36 @@ "Object" ], "typeParameterNames": [] + }, + { + "entryPoints": [ + "posthog_flutter.dart" + ], + "executableDeclarations": [ + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isStatic": false, + "name": "unsubscribe", + "parameters": [], + "relativePath": "lib/src/feature_flags_loaded.dart", + "returnTypeName": "void", + "type": "method", + "typeParameterNames": [] + } + ], + "fieldDeclarations": [], + "isDeprecated": false, + "isExperimental": false, + "isRequired": false, + "isSealed": false, + "name": "PostHogFeatureFlagsSubscription", + "relativePath": "lib/src/feature_flags_loaded.dart", + "superTypeNames": [ + "Object" + ], + "typeParameterNames": [] } ], "iosPlatformConstraints": null, diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index cc6fe120..2d7642bb 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -2,6 +2,7 @@ // of your plugin as a separate package, instead of inlining it in the same // package as the core of your plugin. import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; import 'package:flutter/services.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; @@ -128,7 +129,8 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { WebCanvasMaskProvider(config).register(); - if (ph != null) { + // Skipped for a posthog-js stub without onFeatureFlags. + if (ph != null && (ph as JSObject).has('onFeatureFlags')) { final dartCallback = config.onFeatureFlags; // JS SDK calls with: (flags: string[], variants: Record, context?: {errorsLoading}) diff --git a/posthog_flutter/lib/src/feature_flags_listeners.dart b/posthog_flutter/lib/src/feature_flags_listeners.dart index 9bfbf0ae..f94f299b 100644 --- a/posthog_flutter/lib/src/feature_flags_listeners.dart +++ b/posthog_flutter/lib/src/feature_flags_listeners.dart @@ -4,6 +4,7 @@ import 'package:meta/meta.dart'; import 'feature_flags_loaded.dart'; import 'posthog_flutter_platform_interface.dart'; +import 'util/logging.dart'; /// Delivers each feature flags load to the listeners added with /// [Posthog.onFeatureFlags]. @@ -24,7 +25,7 @@ class FeatureFlagsListeners { // Skipped if a newer load already reached the listener. scheduleMicrotask(() { if (_listeners.containsKey(token) && identical(_last, current)) { - listener(current); + _notify(listener, current); } }); } @@ -35,7 +36,17 @@ class FeatureFlagsListeners { _last = loaded; for (final entry in _listeners.entries.toList()) { // An earlier listener may have unsubscribed this one. - if (_listeners.containsKey(entry.key)) entry.value(loaded); + if (_listeners.containsKey(entry.key)) _notify(entry.value, loaded); + } + } + + // A throwing listener must not stop the others from getting the load. + void _notify( + OnFeatureFlagsCallback listener, PostHogFeatureFlagsLoaded loaded) { + try { + listener(loaded); + } catch (e) { + printIfDebug('onFeatureFlags listener threw: $e'); } } diff --git a/posthog_flutter/test/feature_flags_listeners_test.dart b/posthog_flutter/test/feature_flags_listeners_test.dart index aa318590..1a686ab2 100644 --- a/posthog_flutter/test/feature_flags_listeners_test.dart +++ b/posthog_flutter/test/feature_flags_listeners_test.dart @@ -70,6 +70,17 @@ void main() { expect(received, isEmpty); }); + test('delivers to the other listeners when one throws', () { + subscriptions.add(listeners.add((_) => throw StateError('listener'))); + final received = []; + listen(received); + final load = loaded('a'); + + listeners.deliver(load); + + expect(received, [load]); + }); + test('skips a listener unsubscribed by an earlier one during delivery', () { late PostHogFeatureFlagsSubscription second; subscriptions.add(listeners.add((_) => second.unsubscribe())); From b130bcf559ff3994a203ae209f4419f5bdcff989 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 18:43:46 -0400 Subject: [PATCH 08/15] fix: deliver flag loads to onFeatureFlags listeners before the config callback A config callback that threw kept listeners from getting the load, and a reset() or close() it started was undone when the load was cached after it. Co-Authored-By: Claude Opus 5.5 --- posthog_flutter/lib/posthog_flutter_web.dart | 4 +- .../lib/src/posthog_flutter_desktop.dart | 4 +- .../lib/src/posthog_flutter_io.dart | 4 +- .../test/posthog_flutter_desktop_test.dart | 37 ++++++++++++ .../test/posthog_flutter_io_test.dart | 54 ++++++++++++++++++ .../test/posthog_flutter_web_setup_test.dart | 56 +++++++++++++++++++ 6 files changed, 156 insertions(+), 3 deletions(-) diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index 2d7642bb..7ef2a9ca 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -140,8 +140,10 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { 'variants': jsFlagVariants.dartify(), 'errorsLoading': (jsContext?.dartify() as Map?)?['errorsLoading'], }); - dartCallback?.call(loaded); + // First, so a throwing config callback can't starve listeners and a + // reset() or close() it starts clears this load. FeatureFlagsListeners.instance.deliver(loaded); + dartCallback?.call(loaded); }.toJS; // Repeated setup would otherwise deliver each load twice. diff --git a/posthog_flutter/lib/src/posthog_flutter_desktop.dart b/posthog_flutter/lib/src/posthog_flutter_desktop.dart index ba6ab89d..b8d236ac 100644 --- a/posthog_flutter/lib/src/posthog_flutter_desktop.dart +++ b/posthog_flutter/lib/src/posthog_flutter_desktop.dart @@ -179,8 +179,10 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { ); _featureFlagsUnsubscribe = client.onFeatureFlags(() { final loaded = client.featureFlagsLoaded(); - _config?.onFeatureFlags?.call(loaded); + // First, so a throwing config callback can't starve listeners and a + // reset() or close() it starts clears this load. FeatureFlagsListeners.instance.deliver(loaded); + _config?.onFeatureFlags?.call(loaded); }); final stepsConfig = config.errorTrackingConfig.exceptionSteps; diff --git a/posthog_flutter/lib/src/posthog_flutter_io.dart b/posthog_flutter/lib/src/posthog_flutter_io.dart index 094b3e98..c8550152 100644 --- a/posthog_flutter/lib/src/posthog_flutter_io.dart +++ b/posthog_flutter/lib/src/posthog_flutter_io.dart @@ -76,8 +76,10 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { return null; case 'onFeatureFlagsCallback': final loaded = featureFlagsLoadedFromMap(call.arguments); - _onFeatureFlagsCallback?.call(loaded); + // First, so a throwing config callback can't starve listeners and a + // reset() or close() it starts clears this load. FeatureFlagsListeners.instance.deliver(loaded); + _onFeatureFlagsCallback?.call(loaded); break; case 'pushIdentityProvider': // Returning a value here is the reply the native SDK awaits. diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index 1c33e543..32cd8004 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -1955,6 +1955,43 @@ void main() { expect(received.last.variants, {'variant-flag': 'test-variant'}); }); + test( + 'onFeatureFlags listeners get the load when the config callback throws', + () async { + final server = await LocalPostHogServer.start(); + server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); + FeatureFlagsListeners.instance.clear(); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + final platform = await setUpPlatform(configFor(server) + ..preloadFeatureFlags = false + ..onFeatureFlags = (_) => throw StateError('app callback')); + + await platform.reloadFeatureFlags(); + + expect(received.last.variants, {'variant-flag': 'test-variant'}); + }); + + test('a reset started by the config callback is not undone by the load', + () async { + final server = await LocalPostHogServer.start(); + server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); + FeatureFlagsListeners.instance.clear(); + // What reset() and close() do before reaching the platform. + final platform = await setUpPlatform(configFor(server) + ..preloadFeatureFlags = false + ..onFeatureFlags = (_) => FeatureFlagsListeners.instance.clear()); + + await platform.reloadFeatureFlags(); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + await Future.delayed(Duration.zero); + + expect(received, isEmpty); + }); + test('onFeatureFlags reports the flags cached by the previous launch', () async { final server = await LocalPostHogServer.start(); diff --git a/posthog_flutter/test/posthog_flutter_io_test.dart b/posthog_flutter/test/posthog_flutter_io_test.dart index adc741b1..91c2fdeb 100644 --- a/posthog_flutter/test/posthog_flutter_io_test.dart +++ b/posthog_flutter/test/posthog_flutter_io_test.dart @@ -8,7 +8,9 @@ import 'package:posthog_flutter/src/feature_flags_listeners.dart'; import 'package:posthog_flutter/src/feature_flags_loaded.dart'; import 'package:posthog_flutter/src/posthog_config.dart'; import 'package:posthog_flutter/src/posthog_event.dart'; +import 'package:posthog_flutter/src/posthog.dart'; import 'package:posthog_flutter/src/posthog_flutter_io.dart'; +import 'package:posthog_flutter/src/posthog_flutter_platform_interface.dart'; import 'package:posthog_flutter/src/posthog_internal_events.dart'; // Simplified void callback for feature flags @@ -278,6 +280,58 @@ void main() { expect(received.single.variants, {'on': true}); }); + Future sendNativeFlags() => + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(const MethodCall( + 'onFeatureFlagsCallback', + { + 'variants': {'old-user-only': true}, + 'errorsLoading': false, + }, + )), + (ByteData? data) {}, + ); + + for (final (name, transition) in <(String, Future Function())>[ + ('reset', () => Posthog().reset()), + ('close', () => Posthog().close()), + ]) { + test('a $name started by the config callback is not undone by the load', + () async { + FeatureFlagsListeners.instance.clear(); + PosthogFlutterPlatformInterface.instance = posthogFlutterIO; + Future? pending; + await posthogFlutterIO.setup(PostHogConfig('test_project_token', + onFeatureFlags: (_) => pending ??= transition())); + + await sendNativeFlags(); + await pending; + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + await Future.delayed(Duration.zero); + + expect(received, isEmpty); + }); + } + + test( + 'onFeatureFlags listeners get the load when the config callback throws', + () async { + FeatureFlagsListeners.instance.clear(); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + await posthogFlutterIO.setup(PostHogConfig('test_project_token', + onFeatureFlags: (_) => throw StateError('app callback'))); + + await sendNativeFlags(); + + expect(received.single.variants, {'old-user-only': true}); + }); + test('does not invoke callback when no callback is registered', () async { // Setup without callback testConfig = PostHogConfig('test_project_token'); diff --git a/posthog_flutter/test/posthog_flutter_web_setup_test.dart b/posthog_flutter/test/posthog_flutter_web_setup_test.dart index fec61462..35ae505a 100644 --- a/posthog_flutter/test/posthog_flutter_web_setup_test.dart +++ b/posthog_flutter/test/posthog_flutter_web_setup_test.dart @@ -94,4 +94,60 @@ void main() { expect(received.single.variants, {'on': true}); }); + + List stubPosthogJs() { + final listeners = []; + globalContext.setProperty( + 'posthog'.toJS, + JSObject() + ..setProperty( + 'onFeatureFlags'.toJS, + ((JSFunction callback) { + listeners.add(callback); + return (() => listeners.remove(callback)).toJS; + }).toJS, + )); + addTearDown(() => globalContext.delete('posthog'.toJS)); + return listeners; + } + + void loadFlags(List listeners) { + for (final listener in listeners.toList()) { + listener.callAsFunction(null, ['on'].jsify(), {'on': true}.jsify(), null); + } + } + + test('onFeatureFlags listeners get the load when the config callback throws', + () async { + final listeners = stubPosthogJs(); + FeatureFlagsListeners.instance.clear(); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + + await PosthogFlutterWeb().setup(PostHogConfig('test_project_token', + onFeatureFlags: (_) => throw StateError('app callback'))); + try { + loadFlags(listeners); + } catch (_) {} + + expect(received.single.variants, {'on': true}); + }); + + test('a reset started by the config callback is not undone by the load', + () async { + final listeners = stubPosthogJs(); + FeatureFlagsListeners.instance.clear(); + + // What reset() and close() do before reaching the platform. + await PosthogFlutterWeb().setup(PostHogConfig('test_project_token', + onFeatureFlags: (_) => FeatureFlagsListeners.instance.clear())); + loadFlags(listeners); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + await Future.delayed(Duration.zero); + + expect(received, isEmpty); + }); } From 7aaa9b8c9d55650888c7b0eec69fe36278fc93d4 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 11:52:38 -0400 Subject: [PATCH 09/15] fix(web): stop flag registrations left by close() or the posthog-js loader A registration whose unsubscribe isn't stored yet (close() during the synchronous replay) or never arrives (the loader snippet's stub) now goes inert once setup() or close() runs again. Co-Authored-By: Claude Opus 5.5 --- posthog_flutter/lib/posthog_flutter_web.dart | 23 ++++++-- .../lib/src/posthog_flutter_web_handler.dart | 5 +- .../test/posthog_flutter_web_setup_test.dart | 54 +++++++++++++++++++ 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index 7ef2a9ca..7b283306 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -38,6 +38,10 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { List _beforeSendCallbacks = []; JSFunction? _featureFlagsUnsubscribe; + /// Bumped by each setup() and close(), so a posthog-js registration from an + /// earlier one stops delivering even when it can't be unsubscribed. + int _featureFlagsGeneration = 0; + // posthog-js has no current screen, so the feature events get it from here // the way native SDKs stamp it: the last screen beforeSend accepted, under // its final name, until close(). @@ -132,10 +136,17 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { // Skipped for a posthog-js stub without onFeatureFlags. if (ph != null && (ph as JSObject).has('onFeatureFlags')) { final dartCallback = config.onFeatureFlags; + // Repeated setup would otherwise deliver each load twice. + _featureFlagsUnsubscribe?.callAsFunction(); + _featureFlagsUnsubscribe = null; + final generation = ++_featureFlagsGeneration; // JS SDK calls with: (flags: string[], variants: Record, context?: {errorsLoading}) final jsCallback = (JSArray jsFlags, JSObject jsFlagVariants, [JSObject? jsContext]) { + // The posthog-js loader snippet queues the registration and returns + // no unsubscribe, so a later setup() or close() can't remove it. + if (generation != _featureFlagsGeneration) return; final loaded = featureFlagsLoadedFromMap({ 'variants': jsFlagVariants.dartify(), 'errorsLoading': (jsContext?.dartify() as Map?)?['errorsLoading'], @@ -146,9 +157,14 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { dartCallback?.call(loaded); }.toJS; - // Repeated setup would otherwise deliver each load twice. - _featureFlagsUnsubscribe?.callAsFunction(); - _featureFlagsUnsubscribe = ph.onFeatureFlags(jsCallback); + // posthog-js replays loaded flags synchronously, and a callback may call + // close() or setup() before this returns the unsubscribe. + final unsubscribe = ph.onFeatureFlags(jsCallback); + if (generation == _featureFlagsGeneration) { + _featureFlagsUnsubscribe = unsubscribe; + } else { + unsubscribe?.callAsFunction(); + } } } @@ -514,6 +530,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { _currentScreen = null; _featureFlagsUnsubscribe?.callAsFunction(); _featureFlagsUnsubscribe = null; + _featureFlagsGeneration++; return handleWebMethodCall(const MethodCall('close')); } diff --git a/posthog_flutter/lib/src/posthog_flutter_web_handler.dart b/posthog_flutter/lib/src/posthog_flutter_web_handler.dart index 0eb44ccd..ce7f657d 100644 --- a/posthog_flutter/lib/src/posthog_flutter_web_handler.dart +++ b/posthog_flutter/lib/src/posthog_flutter_web_handler.dart @@ -67,7 +67,8 @@ extension PostHogExtension on PostHog { external void unregister(JSAny key); // ignore: non_constant_identifier_names external JSAny? get_session_id(); - external JSFunction onFeatureFlags(JSFunction callback); + // The loader snippet's stub returns undefined until posthog-js loads. + external JSFunction? onFeatureFlags(JSFunction callback); external void startSessionRecording(); external void stopSessionRecording(); external bool sessionRecordingStarted(); @@ -283,7 +284,7 @@ Future> _reloadFeatureFlags(PostHog? ph) async { onTimeout: () => currentFlags(errorsLoading: true), ); } finally { - unsubscribe.callAsFunction(); + unsubscribe?.callAsFunction(); } } diff --git a/posthog_flutter/test/posthog_flutter_web_setup_test.dart b/posthog_flutter/test/posthog_flutter_web_setup_test.dart index 35ae505a..4ef0f1b4 100644 --- a/posthog_flutter/test/posthog_flutter_web_setup_test.dart +++ b/posthog_flutter/test/posthog_flutter_web_setup_test.dart @@ -150,4 +150,58 @@ void main() { expect(received, isEmpty); }); + + test('close() during the synchronous flag replay removes the registration', + () async { + final listeners = []; + globalContext.setProperty( + 'posthog'.toJS, + JSObject() + ..setProperty( + 'onFeatureFlags'.toJS, + ((JSFunction callback) { + listeners.add(callback); + // posthog-js calls back right away when flags are loaded. + callback.callAsFunction( + null, ['on'].jsify(), {'on': true}.jsify(), null); + return (() => listeners.remove(callback)).toJS; + }).toJS, + )); + addTearDown(() => globalContext.delete('posthog'.toJS)); + final web = PosthogFlutterWeb(); + + await web.setup(PostHogConfig('test_project_token', + onFeatureFlags: (_) => web.close())); + + expect(listeners, isEmpty); + }); + + test('registrations queued by the posthog-js loader stop after close()', + () async { + // The loader snippet queues calls and returns undefined until it loads. + final queued = []; + globalContext.setProperty( + 'posthog'.toJS, + JSObject() + ..setProperty( + 'onFeatureFlags'.toJS, + ((JSFunction callback) { + queued.add(callback); + }).toJS, + )); + addTearDown(() => globalContext.delete('posthog'.toJS)); + FeatureFlagsListeners.instance.clear(); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + final web = PosthogFlutterWeb(); + + await web.setup(PostHogConfig('test_project_token')); + await web.setup(PostHogConfig('test_project_token')); + loadFlags(queued); + await web.close(); + loadFlags(queued); + + expect(received.single.variants, {'on': true}); + }); } From 143199431b9dee8a22376d87d2445a2485c21185 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 11:56:25 -0400 Subject: [PATCH 10/15] fix: drop native flag loads sent before a reset or close reached native Dart bumps a generation when reset() or close() clears the listener registry and sends it with setup, reset and close. Android and iOS tag each onFeatureFlags notification with the generation they had when it fired, and Dart drops ones from an earlier generation, so the previous user's flags can't repopulate the replay cache. Co-Authored-By: Claude Opus 5.5 --- .../posthog/flutter/PosthogFlutterPlugin.kt | 18 +++++- .../PosthogFlutterPlugin.swift | 31 +++++++++- .../lib/src/feature_flags_listeners.dart | 11 +++- .../lib/src/posthog_flutter_io.dart | 17 +++++- .../test/posthog_flutter_io_test.dart | 59 +++++++++++++++---- .../test/snapshots/setup_request.json | 3 +- 6 files changed, 120 insertions(+), 19 deletions(-) diff --git a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index 91f4eb8e..e24a0e01 100644 --- a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -93,6 +93,12 @@ class PosthogFlutterPlugin : @Volatile private var isHostActivityStopped = false + // Dart's feature flags lifecycle, sent with setup, reset and close. Each + // flags notification carries the one current when it fired, so Dart can + // drop loads from before a reset or close. + @Volatile + private var flagsGeneration = 0 + // A count, not a boolean: under multi-resume (Android 10+ split-screen) // two non-host activities can be resumed at once, and pausing one must // not read as "nothing covers the host" while the other still does. @@ -273,6 +279,7 @@ class PosthogFlutterPlugin : } "reset" -> { + updateFlagsGeneration(call) reset(result) } @@ -353,6 +360,7 @@ class PosthogFlutterPlugin : } "close" -> { + updateFlagsGeneration(call) close(result) } @@ -541,6 +549,7 @@ class PosthogFlutterPlugin : return } + updateFlagsGeneration(call) setupPostHog(args) result.success(null) @@ -817,7 +826,10 @@ class PosthogFlutterPlugin : onFeatureFlags = PostHogFeatureFlagsCallback { loaded -> Log.i("PostHogFlutter", "Android onFeatureFlags triggered. Notifying Dart.") - invokeFlutterMethod("onFeatureFlagsCallback", loaded.toFlutterMap()) + invokeFlutterMethod( + "onFeatureFlagsCallback", + loaded.toFlutterMap() + ("generation" to flagsGeneration), + ) } } @@ -2163,6 +2175,10 @@ class PosthogFlutterPlugin : } } + private fun updateFlagsGeneration(call: MethodCall) { + call.argument("flagsGeneration")?.let { flagsGeneration = it } + } + private fun close(result: Result) { try { PostHog.close() diff --git a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift index bb88b762..0f6dcac7 100644 --- a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift +++ b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift @@ -45,6 +45,24 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { private var channel: FlutterMethodChannel? private var featureFlagsSubscription: PostHogFeatureFlagsSubscription? + // Dart's feature flags lifecycle, sent with setup, reset and close. Each + // flags notification carries the one current when it fired, so Dart can + // drop loads from before a reset or close. + private let flagsGenerationLock = NSLock() + private var flagsGenerationValue = 0 + private var flagsGeneration: Int { + get { + flagsGenerationLock.lock() + defer { flagsGenerationLock.unlock() } + return flagsGenerationValue + } + set { + flagsGenerationLock.lock() + flagsGenerationValue = newValue + flagsGenerationLock.unlock() + } + } + // Mint route for pushIdentityProvider, anchored to the engine whose setup() // installed the provider. A live owner is never displaced; provider-enabled // engines stay candidates so detach promotes the most recent survivor instead @@ -88,7 +106,15 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { } func featureFlagsDidUpdate(_ loaded: PostHogFeatureFlagsLoaded) { - invokeFlutterMethod("onFeatureFlagsCallback", arguments: Self.featureFlagsLoadedMap(loaded)) + var arguments = Self.featureFlagsLoadedMap(loaded) + arguments["generation"] = flagsGeneration + invokeFlutterMethod("onFeatureFlagsCallback", arguments: arguments) + } + + private func updateFlagsGeneration(_ call: FlutterMethodCall) { + if let generation = (call.arguments as? [String: Any])?["flagsGeneration"] as? Int { + flagsGeneration = generation + } } private static func featureFlagsLoadedMap(_ loaded: PostHogFeatureFlagsLoaded) -> [String: Any] { @@ -466,6 +492,7 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case "setup": + updateFlagsGeneration(call) setup(call, result: result) case "getFeatureFlag": getFeatureFlag(call, result: result) @@ -494,6 +521,7 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { case "distinctId": distinctId(result) case "reset": + updateFlagsGeneration(call) reset(result) case "enable": enable(result) @@ -526,6 +554,7 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { case "addExceptionStep": addExceptionStep(call, result: result) case "close": + updateFlagsGeneration(call) close(result) case "sendMetaEvent": sendMetaEvent(call, result: result) diff --git a/posthog_flutter/lib/src/feature_flags_listeners.dart b/posthog_flutter/lib/src/feature_flags_listeners.dart index f94f299b..3ca8fa76 100644 --- a/posthog_flutter/lib/src/feature_flags_listeners.dart +++ b/posthog_flutter/lib/src/feature_flags_listeners.dart @@ -16,6 +16,12 @@ class FeatureFlagsListeners { final _listeners = {}; PostHogFeatureFlagsLoaded? _last; + int _generation = 0; + + /// Bumped by [clear]. The native SDKs tag each load with the generation + /// they last got, so a load sent before a `reset()` or `close()` reached + /// them can be told apart. + int get generation => _generation; PostHogFeatureFlagsSubscription add(OnFeatureFlagsCallback listener) { final token = Object(); @@ -52,5 +58,8 @@ class FeatureFlagsListeners { /// Forgets the last load, so new listeners don't get the previous user's /// flags after `reset()` or `close()`. - void clear() => _last = null; + void clear() { + _last = null; + _generation++; + } } diff --git a/posthog_flutter/lib/src/posthog_flutter_io.dart b/posthog_flutter/lib/src/posthog_flutter_io.dart index c8550152..73f5d0e8 100644 --- a/posthog_flutter/lib/src/posthog_flutter_io.dart +++ b/posthog_flutter/lib/src/posthog_flutter_io.dart @@ -75,6 +75,14 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { await cleanupSurveys(); return null; case 'onFeatureFlagsCallback': + final arguments = call.arguments; + // Sent before the native SDK got the latest reset() or close(), so + // these may be the previous user's flags. + if (arguments is! Map || + arguments['generation'] != + FeatureFlagsListeners.instance.generation) { + break; + } final loaded = featureFlagsLoadedFromMap(call.arguments); // First, so a throwing config callback can't starve listeners and a // reset() or close() it starts clears this load. @@ -196,6 +204,9 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { SurveyService().hideSurvey(); } + Map _flagsGenerationArguments() => + {'flagsGeneration': FeatureFlagsListeners.instance.generation}; + /// Flutter to Native Calls /// @override @@ -215,6 +226,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { await _methodChannel.invokeMethod('setup', { ...config.toMap(), ...flutterVersionSetupArguments(), + ..._flagsGenerationArguments(), }); } on PlatformException catch (exception) { printIfDebug('Exeption on setup: $exception'); @@ -512,7 +524,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('reset'); + await _methodChannel.invokeMethod('reset', _flagsGenerationArguments()); } on PlatformException catch (exception) { printIfDebug('Exeption on reset: $exception'); } @@ -892,7 +904,8 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - return await _methodChannel.invokeMethod('close'); + return await _methodChannel.invokeMethod( + 'close', _flagsGenerationArguments()); } on PlatformException catch (exception) { printIfDebug('Exeption on close: $exception'); } diff --git a/posthog_flutter/test/posthog_flutter_io_test.dart b/posthog_flutter/test/posthog_flutter_io_test.dart index 91c2fdeb..f2f4599a 100644 --- a/posthog_flutter/test/posthog_flutter_io_test.dart +++ b/posthog_flutter/test/posthog_flutter_io_test.dart @@ -162,6 +162,14 @@ void main() { }); group('PosthogFlutterIO onFeatureFlags via setup', () { + // The native SDKs tag each load with the generation they last got. + Map nativeFlagsArgs(Map args, + {int? generation}) => + { + ...args, + 'generation': generation ?? FeatureFlagsListeners.instance.generation, + }; + test( 'setup initializes method call handler and registers callback if provided', () async { @@ -181,7 +189,7 @@ void main() { .handlePlatformMessage( channel.name, channel.codec.encodeMethodCall( - const MethodCall('onFeatureFlagsCallback', {}), + MethodCall('onFeatureFlagsCallback', nativeFlagsArgs({})), ), (ByteData? data) {}, ); @@ -224,12 +232,12 @@ void main() { await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .handlePlatformMessage( channel.name, - channel.codec.encodeMethodCall(const MethodCall( + channel.codec.encodeMethodCall(MethodCall( 'onFeatureFlagsCallback', - { + nativeFlagsArgs({ 'variants': {'on': true, 'variant': 'test', 'off': false}, 'errorsLoading': true, - }, + }), )), (ByteData? data) {}, ); @@ -249,7 +257,8 @@ void main() { .handlePlatformMessage( channel.name, channel.codec.encodeMethodCall( - const MethodCall('onFeatureFlagsCallback', {'variants': 123}), + MethodCall( + 'onFeatureFlagsCallback', nativeFlagsArgs({'variants': 123})), ), (ByteData? data) {}, ); @@ -267,12 +276,12 @@ void main() { await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .handlePlatformMessage( channel.name, - channel.codec.encodeMethodCall(const MethodCall( + channel.codec.encodeMethodCall(MethodCall( 'onFeatureFlagsCallback', - { + nativeFlagsArgs({ 'variants': {'on': true}, 'errorsLoading': false, - }, + }), )), (ByteData? data) {}, ); @@ -280,20 +289,44 @@ void main() { expect(received.single.variants, {'on': true}); }); - Future sendNativeFlags() => + Future sendNativeFlags({int? generation}) => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .handlePlatformMessage( channel.name, - channel.codec.encodeMethodCall(const MethodCall( + channel.codec.encodeMethodCall(MethodCall( 'onFeatureFlagsCallback', - { + nativeFlagsArgs({ 'variants': {'old-user-only': true}, 'errorsLoading': false, - }, + }, generation: generation), )), (ByteData? data) {}, ); + for (final (name, transition) in <(String, Future Function())>[ + ('reset', () => Posthog().reset()), + ('close', () => Posthog().close()), + ]) { + test('drops a native load sent before the $name reached native', + () async { + PosthogFlutterPlatformInterface.instance = posthogFlutterIO; + final received = []; + await posthogFlutterIO.setup( + PostHogConfig('test_project_token', onFeatureFlags: received.add)); + final before = FeatureFlagsListeners.instance.generation; + + await transition(); + await sendNativeFlags(generation: before); + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + await Future.delayed(Duration.zero); + + expect(received, isEmpty); + expect((log.last.arguments as Map)['flagsGeneration'], + FeatureFlagsListeners.instance.generation); + }); + } + for (final (name, transition) in <(String, Future Function())>[ ('reset', () => Posthog().reset()), ('close', () => Posthog().close()), @@ -342,7 +375,7 @@ void main() { .handlePlatformMessage( channel.name, channel.codec.encodeMethodCall( - const MethodCall('onFeatureFlagsCallback', {}), + MethodCall('onFeatureFlagsCallback', nativeFlagsArgs({})), ), (ByteData? data) => reply = data, ); diff --git a/posthog_flutter/test/snapshots/setup_request.json b/posthog_flutter/test/snapshots/setup_request.json index 367138b4..53afe1ce 100644 --- a/posthog_flutter/test/snapshots/setup_request.json +++ b/posthog_flutter/test/snapshots/setup_request.json @@ -77,7 +77,8 @@ "color": "blue" } } - } + }, + "flagsGeneration": 1 } } ] From b71f8d6a205f4ea8e6ddbb53b9932d173ff60461 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 16:06:54 -0400 Subject: [PATCH 11/15] fix: start receiving native flag loads when the first listener is added With native auto-init, Posthog().onFeatureFlags() can be the app's only Dart SDK call, and nothing created the platform that installs the channel handler, so native loads never reached the listener. Co-Authored-By: Claude Opus 5.5 --- posthog_flutter/lib/src/posthog.dart | 8 +++-- ...feature_flags_listener_auto_init_test.dart | 34 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 posthog_flutter/test/feature_flags_listener_auto_init_test.dart diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index d08213a6..d44fe063 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -546,8 +546,12 @@ class Posthog { /// subscription.unsubscribe(); /// ``` PostHogFeatureFlagsSubscription onFeatureFlags( - OnFeatureFlagsCallback callback) => - FeatureFlagsListeners.instance.add(callback); + OnFeatureFlagsCallback callback) { + // Creating the platform installs the channel handler native loads arrive + // on. With native auto-init this can be the app's only Dart SDK call. + _posthog; + return FeatureFlagsListeners.instance.add(callback); + } /// Sets person properties that are used only for feature flag evaluation. /// diff --git a/posthog_flutter/test/feature_flags_listener_auto_init_test.dart b/posthog_flutter/test/feature_flags_listener_auto_init_test.dart new file mode 100644 index 00000000..fc5061cb --- /dev/null +++ b/posthog_flutter/test/feature_flags_listener_auto_init_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/posthog_flutter.dart'; + +// Its own file so no earlier test has created the platform instance, as in an +// app where native auto-init ran and onFeatureFlags is the first Dart call. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('onFeatureFlags as the first SDK call receives native loads', () async { + const channel = MethodChannel('posthog_flutter'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => null); + final received = []; + final subscription = Posthog().onFeatureFlags(received.add); + addTearDown(subscription.unsubscribe); + + ServicesBinding.instance.channelBuffers.push( + channel.name, + channel.codec + .encodeMethodCall(const MethodCall('onFeatureFlagsCallback', { + 'generation': 0, + 'variants': {'auto-init-flag': true}, + 'errorsLoading': false, + })), + (_) {}, + ); + await pumpEventQueue(); + + expect(received.map((loaded) => loaded.variants), [ + {'auto-init-flag': true} + ]); + }); +} From 4d81d9e8575b97efeb0b125ac8cb281902a7c4cb Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 16:08:02 -0400 Subject: [PATCH 12/15] fix: deliver Android reloadFeatureFlags results to onFeatureFlags listeners posthog-android passes a manual reload only to its completion, so no onFeatureFlagsCallback arrives and listeners missed the load while new ones replayed the older snapshot. A reload that finishes after a reset or close is still dropped. Co-Authored-By: Claude Opus 5.5 --- .../lib/src/posthog_flutter_io.dart | 11 +++- .../test/posthog_flutter_io_test.dart | 62 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/posthog_flutter/lib/src/posthog_flutter_io.dart b/posthog_flutter/lib/src/posthog_flutter_io.dart index 73f5d0e8..9ec0a3f3 100644 --- a/posthog_flutter/lib/src/posthog_flutter_io.dart +++ b/posthog_flutter/lib/src/posthog_flutter_io.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'util/platform_io_stub.dart' if (dart.library.io) 'util/platform_io_real.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:posthog_flutter/src/surveys/survey_service.dart'; @@ -608,9 +609,17 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { return featureFlagsLoadedFromMap(null); } + final generation = FeatureFlagsListeners.instance.generation; try { - return featureFlagsLoadedFromMap( + final loaded = featureFlagsLoadedFromMap( await _methodChannel.invokeMethod('reloadFeatureFlags')); + // posthog-android hands a manual reload only to its completion, not to + // the config callback that sends onFeatureFlagsCallback. + if (defaultTargetPlatform == TargetPlatform.android && + generation == FeatureFlagsListeners.instance.generation) { + FeatureFlagsListeners.instance.deliver(loaded); + } + return loaded; } on PlatformException catch (exception) { printIfDebug('Exeption on reloadFeatureFlags: $exception'); return featureFlagsLoadedFromMap(null); diff --git a/posthog_flutter/test/posthog_flutter_io_test.dart b/posthog_flutter/test/posthog_flutter_io_test.dart index f2f4599a..43a352bc 100644 --- a/posthog_flutter/test/posthog_flutter_io_test.dart +++ b/posthog_flutter/test/posthog_flutter_io_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/src/feature_flag_result.dart'; @@ -365,6 +366,67 @@ void main() { expect(received.single.variants, {'old-user-only': true}); }); + void mockReload(Future Function() reload) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + log.add(call); + return call.method == 'reloadFeatureFlags' ? reload() : null; + }); + } + + const reloaded = { + 'variants': {'manual': true}, + 'errorsLoading': false, + }; + + test('Android reload result reaches active and later listeners', () async { + FeatureFlagsListeners.instance.clear(); + mockReload(() async => reloaded); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + + await posthogFlutterIO.reloadFeatureFlags(); + final late = []; + final lateSubscription = FeatureFlagsListeners.instance.add(late.add); + addTearDown(lateSubscription.unsubscribe); + await pumpEventQueue(); + + expect(received.single.variants, {'manual': true}); + expect(late.single.variants, {'manual': true}); + }); + + test('iOS reload result is left to the native notification', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + FeatureFlagsListeners.instance.clear(); + mockReload(() async => reloaded); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + + await posthogFlutterIO.reloadFeatureFlags(); + + expect(received, isEmpty); + }); + + test('drops an Android reload result that finishes after a reset', + () async { + PosthogFlutterPlatformInterface.instance = posthogFlutterIO; + final reply = Completer(); + mockReload(() => reply.future); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + + final reload = posthogFlutterIO.reloadFeatureFlags(); + await Posthog().reset(); + reply.complete(reloaded); + await reload; + + expect(received, isEmpty); + }); + test('does not invoke callback when no callback is registered', () async { // Setup without callback testConfig = PostHogConfig('test_project_token'); From 284cdecf6527b354e1d2fa2feacd1cdac2ce7790 Mon Sep 17 00:00:00 2001 From: Anna Garcia <11654201+turnipdabeets@users.noreply.github.com> Date: Thu, 29 Oct 2026 17:03:49 -0400 Subject: [PATCH 13/15] chore: stop passing RRStyle to replay screenshot frames (#684) posthog-android 4.0 drops RRStyle and the wireframe tree fields from the internal RRWireframe; screenshot frames don't need a style. Co-authored-by: Claude Opus 5.5 --- .../src/main/kotlin/com/posthog/flutter/SnapshotSender.kt | 2 -- 1 file changed, 2 deletions(-) diff --git a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/SnapshotSender.kt b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/SnapshotSender.kt index 976da3fd..e242cd98 100644 --- a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/SnapshotSender.kt +++ b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/SnapshotSender.kt @@ -5,7 +5,6 @@ import com.posthog.android.internal.base64 import com.posthog.internal.replay.RREvent import com.posthog.internal.replay.RRFullSnapshotEvent import com.posthog.internal.replay.RRMetaEvent -import com.posthog.internal.replay.RRStyle import com.posthog.internal.replay.RRWireframe import com.posthog.internal.replay.capture @@ -48,7 +47,6 @@ class SnapshotSender( height = height ?: bitmap.height, type = "screenshot", base64 = bitmap.base64(quality = quality), - style = RRStyle(), ) return RRFullSnapshotEvent( listOf(wireframe), From 1538fa6de9331676e91bffaa0e1cdcfba56a209a Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 30 Oct 2026 15:11:25 -0400 Subject: [PATCH 14/15] fix(web): clear replayed flags when a queued reset reaches posthog-js Co-Authored-By: Claude Opus 5.5 --- posthog_flutter/lib/posthog_flutter_web.dart | 9 ++- .../test/posthog_flutter_web_setup_test.dart | 59 +++++++++++++++++++ 2 files changed, 67 insertions(+), 1 deletion(-) diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index 3bcbe38f..338e97e2 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -404,7 +404,14 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { @override Future reset() async { - return _call(const MethodCall('reset')); + return _order.run(() { + // Posthog.reset() cleared this when the app called it, but a load can + // still arrive while the reset waits behind an async beforeSend. + // posthog-js sends no empty load on reset, so nothing else would replace + // that load for listeners added later. + FeatureFlagsListeners.instance.clear(); + return handleWebMethodCall(const MethodCall('reset')); + }); } @override diff --git a/posthog_flutter/test/posthog_flutter_web_setup_test.dart b/posthog_flutter/test/posthog_flutter_web_setup_test.dart index 4ef0f1b4..ded15556 100644 --- a/posthog_flutter/test/posthog_flutter_web_setup_test.dart +++ b/posthog_flutter/test/posthog_flutter_web_setup_test.dart @@ -9,6 +9,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/posthog_flutter.dart'; import 'package:posthog_flutter/posthog_flutter_web.dart'; import 'package:posthog_flutter/src/feature_flags_listeners.dart'; +import 'package:posthog_flutter/src/posthog_flutter_platform_interface.dart'; import 'package:posthog_flutter/src/replay/web/web_canvas_mask_provider.dart'; /// posthog-js is initialized by the host app on web, so options the plugin @@ -117,6 +118,64 @@ void main() { } } + group('a load arriving while reset waits behind an async beforeSend', () { + late List listeners; + late List calls; + late Completer release; + late Future resetting; + + setUp(() async { + FeatureFlagsListeners.instance.clear(); + listeners = stubPosthogJs(); + calls = []; + globalContext.getProperty('posthog'.toJS) + ..setProperty( + 'capture'.toJS, + ((JSString event, [JSAny? a, JSAny? b]) => calls.add('capture')).toJS, + ) + ..setProperty('reset'.toJS, (() => calls.add('reset')).toJS); + release = Completer(); + final web = PosthogFlutterWeb(); + PosthogFlutterPlatformInterface.instance = web; + await web.setup(PostHogConfig('test_project_token', beforeSend: [ + (event) async { + await release.future; + return event; + }, + ])); + + final capturing = Posthog().capture(eventName: 'Logout clicked'); + resetting = Future.wait([capturing, Posthog().reset()]); + await Future.delayed(Duration.zero); + loadFlags(listeners); + expect(calls, isEmpty); + release.complete(); + await resetting; + expect(calls, ['capture', 'reset']); + }); + + test('is not replayed to a listener added after reset completes', () async { + final received = []; + final subscription = Posthog().onFeatureFlags(received.add); + addTearDown(subscription.unsubscribe); + + await Future.delayed(Duration.zero); + + expect(received, isEmpty); + }); + + test('does not stop the next load replaying', () async { + loadFlags(listeners); + final received = []; + final subscription = Posthog().onFeatureFlags(received.add); + addTearDown(subscription.unsubscribe); + + await Future.delayed(Duration.zero); + + expect(received.single.variants, {'on': true}); + }); + }); + test('onFeatureFlags listeners get the load when the config callback throws', () async { final listeners = stubPosthogJs(); From 08a1e41ed833c461c39363edc3cdc89cdec20126 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 30 Oct 2026 22:12:54 -0400 Subject: [PATCH 15/15] docs: fold #670 release notes into the 6.0 docs --- .changeset/v6-major-release.md | 1 + .changeset/v6-on-feature-flags-listener.md | 5 ----- .changeset/v6-posthog-ios-4.md | 11 ----------- posthog_flutter/MIGRATION.md | 1 + 4 files changed, 2 insertions(+), 16 deletions(-) delete mode 100644 .changeset/v6-on-feature-flags-listener.md delete mode 100644 .changeset/v6-posthog-ios-4.md diff --git a/.changeset/v6-major-release.md b/.changeset/v6-major-release.md index 13693637..7d931704 100644 --- a/.changeset/v6-major-release.md +++ b/.changeset/v6-major-release.md @@ -29,6 +29,7 @@ - **Breaking:** send person processing as the `process_person_profile: false` option on Windows and Linux, only while it's off, instead of a `$process_person_profile` property on every event - Add an `options` parameter to `capture`, `screen`, `identify`, `setPersonProperties`, `alias`, `group` and `captureException`, and `PostHogEvent.options` for `beforeSend` callbacks - Add `captureAi` for AI observability events, a no-op that returns `null` on web +- Add `Posthog().onFeatureFlags()` to get notified after every feature flags load, returning a `PostHogFeatureFlagsSubscription` you can `unsubscribe()` - Add `PostHogConfig.captureAiCompression`, `captureAiMaxQueueSize`, `captureAiTimeout` and `captureAiMaxEventBytes` - Apply a `$process_person_profile` property or `process_person_profile` option passed with an event to that event on Windows and Linux - Back off failed sends on Windows and Linux from 1 second up to 30 seconds with ±25% jitter, instead of resending within the flush, treating `Retry-After` as a floor diff --git a/.changeset/v6-on-feature-flags-listener.md b/.changeset/v6-on-feature-flags-listener.md deleted file mode 100644 index 44d2f2e0..00000000 --- a/.changeset/v6-on-feature-flags-listener.md +++ /dev/null @@ -1,5 +0,0 @@ ---- -"posthog_flutter": minor ---- - -- Add `Posthog().onFeatureFlags()` to get notified after every feature flags load, returning a `PostHogFeatureFlagsSubscription` you can `unsubscribe()` diff --git a/.changeset/v6-posthog-ios-4.md b/.changeset/v6-posthog-ios-4.md deleted file mode 100644 index 83f7019f..00000000 --- a/.changeset/v6-posthog-ios-4.md +++ /dev/null @@ -1,11 +0,0 @@ ---- -"posthog_flutter": major ---- - -- **Breaking:** adopt `posthog-ios` 4.x, which requires Xcode 26 -- **Breaking:** adopt `posthog-android` 4.x -- **Breaking:** pass a `PostHogFeatureFlagsLoaded` with the enabled flags, their values and whether loading failed to `PostHogConfig.onFeatureFlags` — update callbacks from `() { ... }` to `(loaded) { ... }` or `(_) { ... }` -- **Breaking:** return the reloaded flags from `reloadFeatureFlags()` as a `Future` -- **Breaking:** return `null` instead of the raw string for a malformed, empty, or whitespace-only `getFeatureFlagResult()` payload on iOS and macOS -- **Breaking:** keep string payloads passed to `PostHogBootstrapConfig.featureFlagPayloads`, such as `"123"`, as strings on iOS and macOS instead of decoding them to numbers or booleans -- **Breaking:** stop sending the legacy `version` and `build` properties on `Application Installed`, `Application Updated` and `Application Opened` on iOS and macOS — use `$app_version` and `$app_build`. Android still sends them until `posthog-android` drops them diff --git a/posthog_flutter/MIGRATION.md b/posthog_flutter/MIGRATION.md index ed7d4bc8..f018f017 100644 --- a/posthog_flutter/MIGRATION.md +++ b/posthog_flutter/MIGRATION.md @@ -264,6 +264,7 @@ On web, `PostHogConfig.sendFeatureFlagEvent` is not applied to posthog-js, so an - An `options` argument on `capture`, `screen`, `identify`, `setPersonProperties`, `alias`, `group` and `captureException`, and `PostHogEvent.options` in `beforeSend`. `process_person_profile`, `cookieless_mode`, `disable_skew_correction` and `product_tour_id` win over their legacy `$` properties. On web, known keys are sent as those legacy properties and other keys are dropped. - `captureAi` for `$ai_*` events. It returns the event's UUID, or `null` when the event isn't admitted. On web it sends nothing and returns `null`. +- `Posthog().onFeatureFlags()` to get notified after every feature flags load. It returns a `PostHogFeatureFlagsSubscription` you can `unsubscribe()`. ## Upgrade checklist