From 00643ec1531f35d7b47840f45c1908faed69016a Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 7 Oct 2026 16:04:18 -0400 Subject: [PATCH 1/6] 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 2/6] 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 3/6] 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 4/6] 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 5/6] 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 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 6/6] 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),