From 705c31157b3cf71b9710088650d1de0d26bb3576 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 23 Oct 2026 12:50:16 -0400 Subject: [PATCH 01/10] 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. --- .changeset/v6-flags-loaded-result.md | 6 + api/posthog_flutter.api.json | 93 ++++++++++++++- example/lib/main.dart | 7 +- .../posthog/flutter/PosthogFlutterPlugin.kt | 12 +- .../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 | 49 ++++++++ posthog_flutter/lib/src/posthog.dart | 22 +++- posthog_flutter/lib/src/posthog_config.dart | 17 ++- .../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 | 17 ++- .../lib/src/utils/channel_serialization.dart | 16 +++ .../test/posthog_flutter_desktop_test.dart | 36 ++++-- .../test/posthog_flutter_io_test.dart | 106 +++++------------- ...sthog_flutter_platform_interface_fake.dart | 4 +- posthog_flutter/test/posthog_test.dart | 2 +- 20 files changed, 335 insertions(+), 124 deletions(-) create mode 100644 .changeset/v6-flags-loaded-result.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..5220460c --- /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. On web it completes right away with the flags loaded so far diff --git a/api/posthog_flutter.api.json b/api/posthog_flutter.api.json index d3767214..c107ecea 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/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index f81e073c..5fd60ae3 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,6 +27,7 @@ import com.posthog.PostHogBeforeSend import com.posthog.PostHogBootstrapConfig import com.posthog.PostHogCompression import com.posthog.PostHogConfig +import com.posthog.PostHogFeatureFlagsLoaded import com.posthog.PostHogOnFeatureFlags import com.posthog.android.PostHogAndroid import com.posthog.android.PostHogAndroidConfig @@ -814,9 +815,9 @@ class PosthogFlutterPlugin : } onFeatureFlags = - PostHogOnFeatureFlags { + PostHogOnFeatureFlags { 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,9 @@ 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 3f26a15b..3e3c3449 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", @@ -1621,9 +1625,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 88047d82..a22c2f50 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 43f7ab84..70a407cb 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'; @@ -923,6 +924,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..084b23b0 --- /dev/null +++ b/posthog_flutter/lib/src/feature_flags_loaded.dart @@ -0,0 +1,49 @@ +/// 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. + 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 2c134fe6..21985626 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'; @@ -489,8 +490,25 @@ 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:** completes right away with the flags posthog-js has + /// loaded so far, and `errorsLoading` is always `false`, because posthog-js + /// does not report when a reload finishes. Use [PostHogConfig.onFeatureFlags] + /// to observe the reloaded flags. + /// + /// **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. /// diff --git a/posthog_flutter/lib/src/posthog_config.dart b/posthog_flutter/lib/src/posthog_config.dart index fcbd806f..613e7f81 100644 --- a/posthog_flutter/lib/src/posthog_config.dart +++ b/posthog_flutter/lib/src/posthog_config.dart @@ -264,8 +264,21 @@ 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`. + /// + /// **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 f8baec36..0d810a51 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 f5a1b2ad..5fe3c3d4 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 993ac9a3..89c7a376 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 1e822330..41b55139 100644 --- a/posthog_flutter/lib/src/posthog_flutter_web_handler.dart +++ b/posthog_flutter/lib/src/posthog_flutter_web_handler.dart @@ -72,10 +72,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 @@ -335,7 +345,12 @@ Future handleWebMethodCall(MethodCall call) async { break; case 'reloadFeatureFlags': posthog?.reloadFeatureFlags(); - break; + // posthog-js doesn't report when the reload finishes, so this is the + // flags loaded so far. + return { + 'variants': posthog?.featureFlags?.getFlagVariants().dartify(), + 'errorsLoading': posthog == null, + }; 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 c2f51696..8202fa0b 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); @@ -1891,6 +1892,27 @@ void main() { expect(server.eventNames, isNot(contains(r'$feature_flag_called'))); }); + 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(); @@ -1907,7 +1929,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 b33e9db0..6b29ef44 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 e4ad6a2b..6d6a6c5d 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_test.dart b/posthog_flutter/test/posthog_test.dart index 4ddb2fa8..f175b2cc 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', From 0fe81a224f97be9a437d579508ea8a5f77cc651c Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 23 Oct 2026 13:11:22 -0400 Subject: [PATCH 02/10] 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. --- .changeset/v6-flags-loaded-result.md | 2 +- posthog_flutter/lib/src/posthog.dart | 9 +- .../lib/src/posthog_flutter_web_handler.dart | 64 +++++++++++-- .../posthog_flutter_web_handler_test.dart | 89 +++++++++++++++++++ 4 files changed, 151 insertions(+), 13 deletions(-) diff --git a/.changeset/v6-flags-loaded-result.md b/.changeset/v6-flags-loaded-result.md index 5220460c..03501e51 100644 --- a/.changeset/v6-flags-loaded-result.md +++ b/.changeset/v6-flags-loaded-result.md @@ -3,4 +3,4 @@ --- - **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. On web it completes right away with the flags loaded so far +- **Breaking:** `reloadFeatureFlags()` now returns a `Future` with the reloaded flags diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index 21985626..f977c9ca 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -495,10 +495,11 @@ class Posthog { /// [PostHogFeatureFlagsLoaded.errorsLoading] is `true` and the flags are the /// last known values. /// - /// **Flutter web:** completes right away with the flags posthog-js has - /// loaded so far, and `errorsLoading` is always `false`, because posthog-js - /// does not report when a reload finishes. Use [PostHogConfig.onFeatureFlags] - /// to observe the reloaded flags. + /// **Flutter web:** completes with the next flags posthog-js reports. When + /// posthog-js skips the request (flags disabled, reloading paused, or after + /// repeated network failures), it completes with the current flags and + /// `errorsLoading: true`: right away when flags are disabled, otherwise + /// after twice posthog-js's `feature_flag_request_timeout_ms`. /// /// **Example:** /// ```dart diff --git a/posthog_flutter/lib/src/posthog_flutter_web_handler.dart b/posthog_flutter/lib/src/posthog_flutter_web_handler.dart index 41b55139..d575fdf6 100644 --- a/posthog_flutter/lib/src/posthog_flutter_web_handler.dart +++ b/posthog_flutter/lib/src/posthog_flutter_web_handler.dart @@ -1,6 +1,8 @@ // ignore_for_file: avoid_dynamic_calls, avoid_annotating_with_dynamic +import 'dart:async'; import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:flutter/services.dart'; @@ -64,7 +66,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(); @@ -210,6 +212,58 @@ String _exceptionMessage(Map properties) { return 'Exception'; } +/// Reloads feature flags and completes with the next flags posthog-js reports. +/// +/// posthog-js skips the request without calling back when flags are disabled +/// (completes right away), and while reloading is paused or after repeated +/// network failures (completes after twice its flags request timeout). Both +/// report the current flags with `errorsLoading` set. +Future> _reloadFeatureFlags(PostHog? ph) async { + final featureFlags = ph?.featureFlags; + if (ph == null || featureFlags == null) { + return {'variants': null, 'errorsLoading': true}; + } + Map currentFlags() => { + 'variants': featureFlags.getFlagVariants().dartify(), + 'errorsLoading': true + }; + + final config = ph.config; + if (config + ?.getProperty('advanced_disable_feature_flags'.toJS) + ?.dartify() == + true) { + return currentFlags(); + } + + 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(); + + final requestTimeoutMs = (config + ?.getProperty('feature_flag_request_timeout_ms'.toJS) + ?.dartify() as num?) + ?.toInt(); + try { + return await reloaded.future.timeout( + Duration(milliseconds: 2 * (requestTimeoutMs ?? 3000)), + onTimeout: currentFlags, + ); + } finally { + unsubscribe.callAsFunction(); + } +} + Future handleWebMethodCall(MethodCall call) async { _maybeOverrideSDKInfo(); @@ -344,13 +398,7 @@ Future handleWebMethodCall(MethodCall call) async { ); break; case 'reloadFeatureFlags': - posthog?.reloadFeatureFlags(); - // posthog-js doesn't report when the reload finishes, so this is the - // flags loaded so far. - return { - 'variants': posthog?.featureFlags?.getFlagVariants().dartify(), - 'errorsLoading': posthog == null, - }; + 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/test/posthog_flutter_web_handler_test.dart b/posthog_flutter/test/posthog_flutter_web_handler_test.dart index 35ab74c0..0f1a70b3 100644 --- a/posthog_flutter/test/posthog_flutter_web_handler_test.dart +++ b/posthog_flutter/test/posthog_flutter_web_handler_test.dart @@ -80,6 +80,95 @@ 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, + }) { + listener = null; + reloads = 0; + unsubscribes = 0; + final current = {'cached': true}.jsify()!; + final featureFlags = JSObject() + ..setProperty('getFlagVariants'.toJS, (() => current).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(() => 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('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('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; From bde0b018e5991f777de6e8d2711f7d8eee0ae9c6 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 23 Oct 2026 15:00:52 -0400 Subject: [PATCH 03/10] chore: use PostHogFeatureFlagsCallback from posthog-android 4.0 --- .../main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt | 4 ++-- 1 file changed, 2 insertions(+), 2 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 5fd60ae3..a28784d7 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,8 +27,8 @@ import com.posthog.PostHogBeforeSend import com.posthog.PostHogBootstrapConfig import com.posthog.PostHogCompression import com.posthog.PostHogConfig +import com.posthog.PostHogFeatureFlagsCallback import com.posthog.PostHogFeatureFlagsLoaded -import com.posthog.PostHogOnFeatureFlags import com.posthog.android.PostHogAndroid import com.posthog.android.PostHogAndroidConfig import com.posthog.android.internal.getApplicationInfo @@ -815,7 +815,7 @@ class PosthogFlutterPlugin : } onFeatureFlags = - PostHogOnFeatureFlags { loaded -> + PostHogFeatureFlagsCallback { loaded -> Log.i("PostHogFlutter", "Android onFeatureFlags triggered. Notifying Dart.") invokeFlutterMethod("onFeatureFlagsCallback", loaded.toFlutterMap()) } From 0c7aea5017bfac97d443505c278b9f9c132e77df Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 23 Oct 2026 15:54:26 -0400 Subject: [PATCH 04/10] chore: fix ktlint function-signature --- .../main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt | 3 +-- 1 file changed, 1 insertion(+), 2 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 a28784d7..8f1ed0eb 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 @@ -2233,8 +2233,7 @@ class PosthogFlutterPlugin : } } - private fun PostHogFeatureFlagsLoaded.toFlutterMap(): Map = - mapOf("variants" to variants, "errorsLoading" to errorsLoading) + private fun PostHogFeatureFlagsLoaded.toFlutterMap(): Map = mapOf("variants" to variants, "errorsLoading" to errorsLoading) private fun invokeFlutterMethod( method: String, From 17d0d812a380217ea1ba998741735dd1fd9576b4 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Fri, 23 Oct 2026 18:27:57 -0400 Subject: [PATCH 05/10] 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. --- posthog_flutter/lib/src/posthog.dart | 12 +++--- .../lib/src/posthog_flutter_web_handler.dart | 32 +++++++++++----- .../posthog_flutter_web_handler_test.dart | 37 ++++++++++++++++++- 3 files changed, 66 insertions(+), 15 deletions(-) diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index f977c9ca..5e0b1efd 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -495,11 +495,13 @@ class Posthog { /// [PostHogFeatureFlagsLoaded.errorsLoading] is `true` and the flags are the /// last known values. /// - /// **Flutter web:** completes with the next flags posthog-js reports. When - /// posthog-js skips the request (flags disabled, reloading paused, or after - /// repeated network failures), it completes with the current flags and - /// `errorsLoading: true`: right away when flags are disabled, otherwise - /// after twice posthog-js's `feature_flag_request_timeout_ms`. + /// **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` after twice its + /// `feature_flag_request_timeout_ms` (right away when flags are disabled). /// /// **Example:** /// ```dart diff --git a/posthog_flutter/lib/src/posthog_flutter_web_handler.dart b/posthog_flutter/lib/src/posthog_flutter_web_handler.dart index d575fdf6..998a757b 100644 --- a/posthog_flutter/lib/src/posthog_flutter_web_handler.dart +++ b/posthog_flutter/lib/src/posthog_flutter_web_handler.dart @@ -212,28 +212,42 @@ String _exceptionMessage(Map properties) { return 'Exception'; } -/// Reloads feature flags and completes with the next flags posthog-js reports. +/// Reloads feature flags and completes with the flags that reload loaded. /// -/// posthog-js skips the request without calling back when flags are disabled -/// (completes right away), and while reloading is paused or after repeated -/// network failures (completes after twice its flags request timeout). Both -/// report the current flags with `errorsLoading` set. +/// 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 after twice its flags request timeout). Future> _reloadFeatureFlags(PostHog? ph) async { final featureFlags = ph?.featureFlags; if (ph == null || featureFlags == null) { return {'variants': null, 'errorsLoading': true}; } - Map currentFlags() => { + Map currentFlags({required bool errorsLoading}) => { 'variants': featureFlags.getFlagVariants().dartify(), - 'errorsLoading': true + '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(); + return currentFlags(errorsLoading: true); } final reloaded = Completer>(); @@ -257,7 +271,7 @@ Future> _reloadFeatureFlags(PostHog? ph) async { try { return await reloaded.future.timeout( Duration(milliseconds: 2 * (requestTimeoutMs ?? 3000)), - onTimeout: currentFlags, + onTimeout: () => currentFlags(errorsLoading: true), ); } finally { unsubscribe.callAsFunction(); diff --git a/posthog_flutter/test/posthog_flutter_web_handler_test.dart b/posthog_flutter/test/posthog_flutter_web_handler_test.dart index 0f1a70b3..116f7c54 100644 --- a/posthog_flutter/test/posthog_flutter_web_handler_test.dart +++ b/posthog_flutter/test/posthog_flutter_web_handler_test.dart @@ -89,13 +89,23 @@ void main() { void installFake({ Map config = const {}, ({Map variants, bool errorsLoading})? answer, + String? asyncStatus, }) { listener = null; reloads = 0; unsubscribes = 0; - final current = {'cached': true}.jsify()!; + 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()) @@ -138,6 +148,31 @@ void main() { 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}); From d1e15d402a706a58eac008ea16422dc565ba920f Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Sun, 25 Oct 2026 12:39:51 -0400 Subject: [PATCH 06/10] docs: pass the loaded flags in the setup example --- posthog_flutter/lib/src/posthog.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index 5e0b1efd..e2a72288 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -59,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); From 0a7ab2a01000dd97bb0fbb68310592edbf3c3f38 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Sun, 25 Oct 2026 13:03:22 -0400 Subject: [PATCH 07/10] docs: drop best-effort web note from the person and group property methods --- posthog_flutter/lib/src/posthog.dart | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index e2a72288..b10e4f3b 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -527,9 +527,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 @@ -557,8 +557,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 { @@ -581,8 +581,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 @@ -613,8 +613,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, From a4060208f4996fda68c57abca4a6b3ac67ab98a8 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 14:25:30 -0400 Subject: [PATCH 08/10] 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. --- .../lib/src/posthog_flutter_web_handler.dart | 21 ++++++++---- .../posthog_flutter_web_handler_test.dart | 34 +++++++++++++++---- pubspec.lock | 28 +++++++-------- 3 files changed, 57 insertions(+), 26 deletions(-) diff --git a/posthog_flutter/lib/src/posthog_flutter_web_handler.dart b/posthog_flutter/lib/src/posthog_flutter_web_handler.dart index 998a757b..fd482daa 100644 --- a/posthog_flutter/lib/src/posthog_flutter_web_handler.dart +++ b/posthog_flutter/lib/src/posthog_flutter_web_handler.dart @@ -3,6 +3,7 @@ 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'; @@ -212,6 +213,9 @@ 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 @@ -222,7 +226,8 @@ String _exceptionMessage(Map properties) { /// 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 after twice its flags request timeout). +/// (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) { @@ -264,13 +269,17 @@ Future> _reloadFeatureFlags(PostHog? ph) async { subscribed = true; ph.reloadFeatureFlags(); - final requestTimeoutMs = (config - ?.getProperty('feature_flag_request_timeout_ms'.toJS) - ?.dartify() as num?) - ?.toInt(); + 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: 2 * (requestTimeoutMs ?? 3000)), + Duration( + milliseconds: + (retries + 2) * requestTimeoutMs + retries * _flagsRetryDelayMs), onTimeout: () => currentFlags(errorsLoading: true), ); } finally { diff --git a/posthog_flutter/test/posthog_flutter_web_handler_test.dart b/posthog_flutter/test/posthog_flutter_web_handler_test.dart index 116f7c54..97c48b9f 100644 --- a/posthog_flutter/test/posthog_flutter_web_handler_test.dart +++ b/posthog_flutter/test/posthog_flutter_web_handler_test.dart @@ -89,6 +89,7 @@ void main() { void installFake({ Map config = const {}, ({Map variants, bool errorsLoading})? answer, + Duration answerAfter = Duration.zero, String? asyncStatus, }) { listener = null; @@ -122,12 +123,14 @@ void main() { (() { reloads++; if (answer == null) return; - Future(() => listener?.callAsFunction( - null, - answer.variants.keys.toList().jsify(), - answer.variants.jsify(), - {'errorsLoading': answer.errorsLoading}.jsify(), - )); + Future.delayed( + answerAfter, + () => listener?.callAsFunction( + null, + answer.variants.keys.toList().jsify(), + answer.variants.jsify(), + {'errorsLoading': answer.errorsLoading}.jsify(), + )); }).toJS, ); globalContext.setProperty('posthog'.toJS, fake); @@ -188,6 +191,25 @@ void main() { 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}); 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 0e8a97b0c73eaed9ae4a292207940826dbe07a72 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 14:26:08 -0400 Subject: [PATCH 09/10] docs: note where onFeatureFlags and errorsLoading differ on Android and web --- posthog_flutter/lib/src/feature_flags_loaded.dart | 4 ++++ posthog_flutter/lib/src/posthog_config.dart | 3 +++ 2 files changed, 7 insertions(+) diff --git a/posthog_flutter/lib/src/feature_flags_loaded.dart b/posthog_flutter/lib/src/feature_flags_loaded.dart index 084b23b0..0a8c196b 100644 --- a/posthog_flutter/lib/src/feature_flags_loaded.dart +++ b/posthog_flutter/lib/src/feature_flags_loaded.dart @@ -30,6 +30,10 @@ class PostHogFeatureFlagsLoaded { /// 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 diff --git a/posthog_flutter/lib/src/posthog_config.dart b/posthog_flutter/lib/src/posthog_config.dart index 613e7f81..b5225fd7 100644 --- a/posthog_flutter/lib/src/posthog_config.dart +++ b/posthog_flutter/lib/src/posthog_config.dart @@ -270,6 +270,9 @@ class PostHogConfig { /// 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('') From 288fce285ad42b97aad24a966ff956c6ba0eb002 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 18:13:37 -0400 Subject: [PATCH 10/10] docs: describe the older posthog-js reload timeout as the full retry budget Co-Authored-By: Claude Opus 5.5 --- posthog_flutter/lib/src/posthog.dart | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index b10e4f3b..6504267b 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -500,8 +500,10 @@ class Posthog { /// 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` after twice its - /// `feature_flag_request_timeout_ms` (right away when flags are disabled). + /// 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