diff --git a/.changeset/v6-on-feature-flags-listener.md b/.changeset/v6-on-feature-flags-listener.md new file mode 100644 index 00000000..44d2f2e0 --- /dev/null +++ b/.changeset/v6-on-feature-flags-listener.md @@ -0,0 +1,5 @@ +--- +"posthog_flutter": minor +--- + +- Add `Posthog().onFeatureFlags()` to get notified after every feature flags load, returning a `PostHogFeatureFlagsSubscription` you can `unsubscribe()` diff --git a/api/posthog_flutter.api.json b/api/posthog_flutter.api.json index c107ecea..b676e1ce 100644 --- a/api/posthog_flutter.api.json +++ b/api/posthog_flutter.api.json @@ -4526,6 +4526,28 @@ "type": "method", "typeParameterNames": [] }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isStatic": false, + "name": "onFeatureFlags", + "parameters": [ + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": false, + "isRequired": true, + "name": "callback", + "relativePath": "lib/src/posthog.dart", + "typeName": "void Function(PostHogFeatureFlagsLoaded)" + } + ], + "relativePath": "lib/src/posthog.dart", + "returnTypeName": "PostHogFeatureFlagsSubscription", + "type": "method", + "typeParameterNames": [] + }, { "entryPoints": [], "isDeprecated": false, @@ -6109,6 +6131,36 @@ "Object" ], "typeParameterNames": [] + }, + { + "entryPoints": [ + "posthog_flutter.dart" + ], + "executableDeclarations": [ + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isStatic": false, + "name": "unsubscribe", + "parameters": [], + "relativePath": "lib/src/feature_flags_loaded.dart", + "returnTypeName": "void", + "type": "method", + "typeParameterNames": [] + } + ], + "fieldDeclarations": [], + "isDeprecated": false, + "isExperimental": false, + "isRequired": false, + "isSealed": false, + "name": "PostHogFeatureFlagsSubscription", + "relativePath": "lib/src/feature_flags_loaded.dart", + "superTypeNames": [ + "Object" + ], + "typeParameterNames": [] } ], "iosPlatformConstraints": null, diff --git a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index 8f1ed0eb..3451f383 100644 --- a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -93,6 +93,12 @@ class PosthogFlutterPlugin : @Volatile private var isHostActivityStopped = false + // Dart's feature flags lifecycle, sent with setup, reset and close. Each + // flags notification carries the one current when it fired, so Dart can + // drop loads from before a reset or close. + @Volatile + private var flagsGeneration = 0 + // A count, not a boolean: under multi-resume (Android 10+ split-screen) // two non-host activities can be resumed at once, and pausing one must // not read as "nothing covers the host" while the other still does. @@ -273,6 +279,7 @@ class PosthogFlutterPlugin : } "reset" -> { + updateFlagsGeneration(call) reset(result) } @@ -353,6 +360,7 @@ class PosthogFlutterPlugin : } "close" -> { + updateFlagsGeneration(call) close(result) } @@ -541,6 +549,7 @@ class PosthogFlutterPlugin : return } + updateFlagsGeneration(call) setupPostHog(args) result.success(null) @@ -817,7 +826,10 @@ class PosthogFlutterPlugin : onFeatureFlags = PostHogFeatureFlagsCallback { loaded -> Log.i("PostHogFlutter", "Android onFeatureFlags triggered. Notifying Dart.") - invokeFlutterMethod("onFeatureFlagsCallback", loaded.toFlutterMap()) + invokeFlutterMethod( + "onFeatureFlagsCallback", + loaded.toFlutterMap() + ("generation" to flagsGeneration), + ) } } @@ -2163,6 +2175,10 @@ class PosthogFlutterPlugin : } } + private fun updateFlagsGeneration(call: MethodCall) { + call.argument("flagsGeneration")?.let { flagsGeneration = it } + } + private fun close(result: Result) { try { PostHog.close() diff --git a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift index 3e3c3449..9b3ef473 100644 --- a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift +++ b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift @@ -45,6 +45,24 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { private var channel: FlutterMethodChannel? private var featureFlagsSubscription: PostHogFeatureFlagsSubscription? + // Dart's feature flags lifecycle, sent with setup, reset and close. Each + // flags notification carries the one current when it fired, so Dart can + // drop loads from before a reset or close. + private let flagsGenerationLock = NSLock() + private var flagsGenerationValue = 0 + private var flagsGeneration: Int { + get { + flagsGenerationLock.lock() + defer { flagsGenerationLock.unlock() } + return flagsGenerationValue + } + set { + flagsGenerationLock.lock() + flagsGenerationValue = newValue + flagsGenerationLock.unlock() + } + } + // Mint route for pushIdentityProvider, anchored to the engine whose setup() // installed the provider. A live owner is never displaced; provider-enabled // engines stay candidates so detach promotes the most recent survivor instead @@ -88,7 +106,15 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { } func featureFlagsDidUpdate(_ loaded: PostHogFeatureFlagsLoaded) { - invokeFlutterMethod("onFeatureFlagsCallback", arguments: Self.featureFlagsLoadedMap(loaded)) + var arguments = Self.featureFlagsLoadedMap(loaded) + arguments["generation"] = flagsGeneration + invokeFlutterMethod("onFeatureFlagsCallback", arguments: arguments) + } + + private func updateFlagsGeneration(_ call: FlutterMethodCall) { + if let generation = (call.arguments as? [String: Any])?["flagsGeneration"] as? Int { + flagsGeneration = generation + } } private static func featureFlagsLoadedMap(_ loaded: PostHogFeatureFlagsLoaded) -> [String: Any] { @@ -466,6 +492,7 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case "setup": + updateFlagsGeneration(call) setup(call, result: result) case "getFeatureFlag": getFeatureFlag(call, result: result) @@ -494,6 +521,7 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { case "distinctId": distinctId(result) case "reset": + updateFlagsGeneration(call) reset(result) case "enable": enable(result) @@ -526,6 +554,7 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { case "addExceptionStep": addExceptionStep(call, result: result) case "close": + updateFlagsGeneration(call) close(result) case "sendMetaEvent": sendMetaEvent(call, result: result) diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index a22c2f50..d12760f5 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -2,6 +2,7 @@ // of your plugin as a separate package, instead of inlining it in the same // package as the core of your plugin. import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; import 'package:flutter/services.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; @@ -11,6 +12,7 @@ import 'package:posthog_flutter/src/utils/channel_serialization.dart'; import 'package:posthog_flutter/src/utils/property_normalizer.dart'; import 'src/feature_flag_result.dart'; +import 'src/feature_flags_listeners.dart'; import 'src/feature_flags_loaded.dart'; import 'src/logs/posthog_log_severity.dart'; import 'src/posthog_config.dart'; @@ -34,6 +36,11 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { /// Copied at setup. Event beforeSend runs here, before posthog-js, the same /// way [PosthogFlutterIO] runs it before the platform channel. List _beforeSendCallbacks = []; + JSFunction? _featureFlagsUnsubscribe; + + /// Bumped by each setup() and close(), so a posthog-js registration from an + /// earlier one stops delivering even when it can't be unsubscribed. + int _featureFlagsGeneration = 0; // posthog-js has no current screen, so the feature events get it from here // the way native SDKs stamp it: the last screen beforeSend accepted, under @@ -126,19 +133,38 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { WebCanvasMaskProvider(config).register(); - if (config.onFeatureFlags != null && ph != null) { - final dartCallback = config.onFeatureFlags!; + // Skipped for a posthog-js stub without onFeatureFlags. + if (ph != null && (ph as JSObject).has('onFeatureFlags')) { + final dartCallback = config.onFeatureFlags; + // Repeated setup would otherwise deliver each load twice. + _featureFlagsUnsubscribe?.callAsFunction(); + _featureFlagsUnsubscribe = null; + final generation = ++_featureFlagsGeneration; // JS SDK calls with: (flags: string[], variants: Record, context?: {errorsLoading}) final jsCallback = (JSArray jsFlags, JSObject jsFlagVariants, [JSObject? jsContext]) { - dartCallback(featureFlagsLoadedFromMap({ + // The posthog-js loader snippet queues the registration and returns + // no unsubscribe, so a later setup() or close() can't remove it. + if (generation != _featureFlagsGeneration) return; + final loaded = featureFlagsLoadedFromMap({ 'variants': jsFlagVariants.dartify(), 'errorsLoading': (jsContext?.dartify() as Map?)?['errorsLoading'], - })); + }); + // First, so a throwing config callback can't starve listeners and a + // reset() or close() it starts clears this load. + FeatureFlagsListeners.instance.deliver(loaded); + dartCallback?.call(loaded); }.toJS; - ph.onFeatureFlags(jsCallback); + // posthog-js replays loaded flags synchronously, and a callback may call + // close() or setup() before this returns the unsubscribe. + final unsubscribe = ph.onFeatureFlags(jsCallback); + if (generation == _featureFlagsGeneration) { + _featureFlagsUnsubscribe = unsubscribe; + } else { + unsubscribe?.callAsFunction(); + } } } @@ -500,6 +526,9 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { @override Future close() async { _currentScreen = null; + _featureFlagsUnsubscribe?.callAsFunction(); + _featureFlagsUnsubscribe = null; + _featureFlagsGeneration++; return handleWebMethodCall(const MethodCall('close')); } diff --git a/posthog_flutter/lib/src/feature_flags_listeners.dart b/posthog_flutter/lib/src/feature_flags_listeners.dart new file mode 100644 index 00000000..3ca8fa76 --- /dev/null +++ b/posthog_flutter/lib/src/feature_flags_listeners.dart @@ -0,0 +1,65 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; + +import 'feature_flags_loaded.dart'; +import 'posthog_flutter_platform_interface.dart'; +import 'util/logging.dart'; + +/// Delivers each feature flags load to the listeners added with +/// [Posthog.onFeatureFlags]. +@internal +class FeatureFlagsListeners { + FeatureFlagsListeners._(); + + static final instance = FeatureFlagsListeners._(); + + final _listeners = {}; + PostHogFeatureFlagsLoaded? _last; + int _generation = 0; + + /// Bumped by [clear]. The native SDKs tag each load with the generation + /// they last got, so a load sent before a `reset()` or `close()` reached + /// them can be told apart. + int get generation => _generation; + + PostHogFeatureFlagsSubscription add(OnFeatureFlagsCallback listener) { + final token = Object(); + _listeners[token] = listener; + final current = _last; + if (current != null) { + // Skipped if a newer load already reached the listener. + scheduleMicrotask(() { + if (_listeners.containsKey(token) && identical(_last, current)) { + _notify(listener, current); + } + }); + } + return PostHogFeatureFlagsSubscription(() => _listeners.remove(token)); + } + + void deliver(PostHogFeatureFlagsLoaded loaded) { + _last = loaded; + for (final entry in _listeners.entries.toList()) { + // An earlier listener may have unsubscribed this one. + if (_listeners.containsKey(entry.key)) _notify(entry.value, loaded); + } + } + + // A throwing listener must not stop the others from getting the load. + void _notify( + OnFeatureFlagsCallback listener, PostHogFeatureFlagsLoaded loaded) { + try { + listener(loaded); + } catch (e) { + printIfDebug('onFeatureFlags listener threw: $e'); + } + } + + /// Forgets the last load, so new listeners don't get the previous user's + /// flags after `reset()` or `close()`. + void clear() { + _last = null; + _generation++; + } +} diff --git a/posthog_flutter/lib/src/feature_flags_loaded.dart b/posthog_flutter/lib/src/feature_flags_loaded.dart index 0a8c196b..8efade70 100644 --- a/posthog_flutter/lib/src/feature_flags_loaded.dart +++ b/posthog_flutter/lib/src/feature_flags_loaded.dart @@ -1,3 +1,5 @@ +import 'package:meta/meta.dart'; + /// The feature flags passed to [PostHogConfig.onFeatureFlags] and returned by /// [Posthog.reloadFeatureFlags]. /// @@ -51,3 +53,18 @@ class PostHogFeatureFlagsLoaded { String toString() => 'PostHogFeatureFlagsLoaded(variants: $variants, errorsLoading: $errorsLoading)'; } + +/// A listener added with [Posthog.onFeatureFlags]. +/// +/// Call [unsubscribe] to stop receiving feature flags updates. +class PostHogFeatureFlagsSubscription { + final void Function() _unsubscribe; + + /// Not part of the public API. + @internal + PostHogFeatureFlagsSubscription(this._unsubscribe); + + /// Stops the listener from receiving further updates. Calling it again has + /// no effect. + void unsubscribe() => _unsubscribe(); +} diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index 6504267b..ea326c03 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -5,6 +5,7 @@ import 'package:meta/meta.dart'; import 'package:posthog_flutter/src/error_tracking/posthog_error_tracking_autocapture_integration.dart'; import 'package:posthog_flutter/src/error_tracking/posthog_exception.dart'; import 'feature_flag_result.dart'; +import 'feature_flags_listeners.dart'; import 'feature_flags_loaded.dart'; import 'logs/posthog_log_record.dart'; import 'logs/posthog_log_severity.dart'; @@ -389,6 +390,7 @@ class Posthog { // Bumped before the platform call because native rotates inside that round // trip. PostHogInternalEvents.requestReplaySessionReset(); + FeatureFlagsListeners.instance.clear(); await _posthog.reset(); } @@ -515,6 +517,42 @@ class Posthog { Future reloadFeatureFlags() => _posthog.reloadFeatureFlags(); + /// Registers a [callback] that's invoked whenever feature flags are loaded + /// or updated. + /// + /// It's invoked after the SDK loads flags at startup (including from + /// bootstrap values), after [reloadFeatureFlags], and after calls that + /// reload flags such as [identify], [group] or [reset]. If a load fails, + /// [PostHogFeatureFlagsLoaded.errorsLoading] is `true` and the values are + /// the last known flags. If flags have already loaded when you register, + /// it's also invoked with the current values shortly afterwards. + /// + /// You can register before [setup], and the callback stays registered + /// across [close] and [setup]. Treat each invocation as "flags may have + /// changed", not as an exactly-once event. + /// + /// Returns a subscription; call + /// [PostHogFeatureFlagsSubscription.unsubscribe] to stop receiving updates. + /// + /// **Example:** + /// ```dart + /// final subscription = Posthog().onFeatureFlags((loaded) { + /// if (loaded.variants['new-checkout'] == 'test') { + /// // Show the test variant. + /// } + /// }); + /// + /// // Later, stop listening. + /// subscription.unsubscribe(); + /// ``` + PostHogFeatureFlagsSubscription onFeatureFlags( + OnFeatureFlagsCallback callback) { + // Creating the platform installs the channel handler native loads arrive + // on. With native auto-init this can be the app's only Dart SDK call. + _posthog; + return FeatureFlagsListeners.instance.add(callback); + } + /// Sets person properties that are used only for feature flag evaluation. /// /// Docs: https://posthog.com/docs/feature-flags @@ -1031,6 +1069,7 @@ class Posthog { // recording that follows must start clean either way. PostHogInternalEvents.requestReplaySessionReset(); PosthogObserver.clearCurrentContext(); + FeatureFlagsListeners.instance.clear(); // Uninstall Flutter integrations _uninstallFlutterIntegrations(); diff --git a/posthog_flutter/lib/src/posthog_flutter_desktop.dart b/posthog_flutter/lib/src/posthog_flutter_desktop.dart index 0d810a51..2b19b28b 100644 --- a/posthog_flutter/lib/src/posthog_flutter_desktop.dart +++ b/posthog_flutter/lib/src/posthog_flutter_desktop.dart @@ -6,6 +6,7 @@ import 'core/file_storage.dart'; import 'core/utils/utils.dart'; import 'error_tracking/dart_exception_processor.dart'; import 'feature_flag_result.dart'; +import 'feature_flags_listeners.dart'; import 'feature_flags_loaded.dart'; import 'logs/posthog_log_severity.dart'; import 'posthog_config.dart'; @@ -176,8 +177,13 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { projectToken: config.projectToken, host: removeTrailingSlash(config.host), ); - _featureFlagsUnsubscribe = client.onFeatureFlags( - () => _config?.onFeatureFlags?.call(client.featureFlagsLoaded())); + _featureFlagsUnsubscribe = client.onFeatureFlags(() { + final loaded = client.featureFlagsLoaded(); + // First, so a throwing config callback can't starve listeners and a + // reset() or close() it starts clears this load. + FeatureFlagsListeners.instance.deliver(loaded); + _config?.onFeatureFlags?.call(loaded); + }); final stepsConfig = config.errorTrackingConfig.exceptionSteps; _exceptionSteps = stepsConfig.enabled diff --git a/posthog_flutter/lib/src/posthog_flutter_io.dart b/posthog_flutter/lib/src/posthog_flutter_io.dart index 5fe3c3d4..e2377c82 100644 --- a/posthog_flutter/lib/src/posthog_flutter_io.dart +++ b/posthog_flutter/lib/src/posthog_flutter_io.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'util/platform_io_stub.dart' if (dart.library.io) 'util/platform_io_real.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:posthog_flutter/src/surveys/survey_service.dart'; @@ -17,6 +18,7 @@ import 'utils/flutter_version.dart'; import 'utils/property_normalizer.dart'; import 'feature_flag_result.dart'; +import 'feature_flags_listeners.dart'; import 'feature_flags_loaded.dart'; import 'logs/posthog_log_severity.dart'; import 'posthog_config.dart'; @@ -74,8 +76,19 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { await cleanupSurveys(); return null; case 'onFeatureFlagsCallback': - _onFeatureFlagsCallback - ?.call(featureFlagsLoadedFromMap(call.arguments)); + final arguments = call.arguments; + // Sent before the native SDK got the latest reset() or close(), so + // these may be the previous user's flags. + if (arguments is! Map || + arguments['generation'] != + FeatureFlagsListeners.instance.generation) { + break; + } + final loaded = featureFlagsLoadedFromMap(call.arguments); + // First, so a throwing config callback can't starve listeners and a + // reset() or close() it starts clears this load. + FeatureFlagsListeners.instance.deliver(loaded); + _onFeatureFlagsCallback?.call(loaded); break; case 'pushIdentityProvider': // Returning a value here is the reply the native SDK awaits. @@ -192,6 +205,9 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { SurveyService().hideSurvey(); } + Map _flagsGenerationArguments() => + {'flagsGeneration': FeatureFlagsListeners.instance.generation}; + /// Flutter to Native Calls /// @override @@ -211,6 +227,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { await _methodChannel.invokeMethod('setup', { ...config.toMap(), ...flutterVersionSetupArguments(), + ..._flagsGenerationArguments(), }); } on PlatformException catch (exception) { printIfDebug('Exeption on setup: $exception'); @@ -508,7 +525,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('reset'); + await _methodChannel.invokeMethod('reset', _flagsGenerationArguments()); } on PlatformException catch (exception) { printIfDebug('Exeption on reset: $exception'); } @@ -592,9 +609,17 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { return featureFlagsLoadedFromMap(null); } + final generation = FeatureFlagsListeners.instance.generation; try { - return featureFlagsLoadedFromMap( + final loaded = featureFlagsLoadedFromMap( await _methodChannel.invokeMethod('reloadFeatureFlags')); + // posthog-android hands a manual reload only to its completion, not to + // the config callback that sends onFeatureFlagsCallback. + if (defaultTargetPlatform == TargetPlatform.android && + generation == FeatureFlagsListeners.instance.generation) { + FeatureFlagsListeners.instance.deliver(loaded); + } + return loaded; } on PlatformException catch (exception) { printIfDebug('Exeption on reloadFeatureFlags: $exception'); return featureFlagsLoadedFromMap(null); @@ -888,7 +913,8 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - return await _methodChannel.invokeMethod('close'); + return await _methodChannel.invokeMethod( + 'close', _flagsGenerationArguments()); } on PlatformException catch (exception) { printIfDebug('Exeption on close: $exception'); } diff --git a/posthog_flutter/lib/src/posthog_flutter_web_handler.dart b/posthog_flutter/lib/src/posthog_flutter_web_handler.dart index fd482daa..cf7374a6 100644 --- a/posthog_flutter/lib/src/posthog_flutter_web_handler.dart +++ b/posthog_flutter/lib/src/posthog_flutter_web_handler.dart @@ -67,7 +67,8 @@ extension PostHogExtension on PostHog { external void unregister(JSAny key); // ignore: non_constant_identifier_names external JSAny? get_session_id(); - external JSFunction onFeatureFlags(JSFunction callback); + // The loader snippet's stub returns undefined until posthog-js loads. + external JSFunction? onFeatureFlags(JSFunction callback); external void startSessionRecording(); external void stopSessionRecording(); external bool sessionRecordingStarted(); @@ -283,7 +284,7 @@ Future> _reloadFeatureFlags(PostHog? ph) async { onTimeout: () => currentFlags(errorsLoading: true), ); } finally { - unsubscribe.callAsFunction(); + unsubscribe?.callAsFunction(); } } diff --git a/posthog_flutter/test/feature_flags_listener_auto_init_test.dart b/posthog_flutter/test/feature_flags_listener_auto_init_test.dart new file mode 100644 index 00000000..fc5061cb --- /dev/null +++ b/posthog_flutter/test/feature_flags_listener_auto_init_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/posthog_flutter.dart'; + +// Its own file so no earlier test has created the platform instance, as in an +// app where native auto-init ran and onFeatureFlags is the first Dart call. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('onFeatureFlags as the first SDK call receives native loads', () async { + const channel = MethodChannel('posthog_flutter'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => null); + final received = []; + final subscription = Posthog().onFeatureFlags(received.add); + addTearDown(subscription.unsubscribe); + + ServicesBinding.instance.channelBuffers.push( + channel.name, + channel.codec + .encodeMethodCall(const MethodCall('onFeatureFlagsCallback', { + 'generation': 0, + 'variants': {'auto-init-flag': true}, + 'errorsLoading': false, + })), + (_) {}, + ); + await pumpEventQueue(); + + expect(received.map((loaded) => loaded.variants), [ + {'auto-init-flag': true} + ]); + }); +} diff --git a/posthog_flutter/test/feature_flags_listeners_test.dart b/posthog_flutter/test/feature_flags_listeners_test.dart new file mode 100644 index 00000000..1a686ab2 --- /dev/null +++ b/posthog_flutter/test/feature_flags_listeners_test.dart @@ -0,0 +1,94 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/feature_flags_listeners.dart'; +import 'package:posthog_flutter/src/feature_flags_loaded.dart'; + +PostHogFeatureFlagsLoaded loaded(String variant) => PostHogFeatureFlagsLoaded( + variants: {'flag': variant}, errorsLoading: false); + +void main() { + final listeners = FeatureFlagsListeners.instance; + final subscriptions = []; + + PostHogFeatureFlagsSubscription listen( + List received) { + final subscription = listeners.add(received.add); + subscriptions.add(subscription); + return subscription; + } + + tearDown(() { + for (final subscription in subscriptions) { + subscription.unsubscribe(); + } + subscriptions.clear(); + listeners.clear(); + }); + + test('delivers each load until unsubscribed', () { + final received = []; + final subscription = listen(received); + final first = loaded('a'); + + listeners.deliver(first); + subscription.unsubscribe(); + listeners.deliver(loaded('b')); + + expect(received, [first]); + }); + + test('replays the last load to a new listener once', () async { + final last = loaded('a'); + listeners.deliver(last); + final received = []; + listen(received); + + await pumpEventQueue(); + + expect(received, [last]); + }); + + test('skips the replay when a newer load arrives first', () async { + listeners.deliver(loaded('a')); + final received = []; + listen(received); + final newer = loaded('b'); + listeners.deliver(newer); + + await pumpEventQueue(); + + expect(received, [newer]); + }); + + test('does not replay after clear', () async { + listeners.deliver(loaded('a')); + listeners.clear(); + final received = []; + listen(received); + + await pumpEventQueue(); + + expect(received, isEmpty); + }); + + test('delivers to the other listeners when one throws', () { + subscriptions.add(listeners.add((_) => throw StateError('listener'))); + final received = []; + listen(received); + final load = loaded('a'); + + listeners.deliver(load); + + expect(received, [load]); + }); + + test('skips a listener unsubscribed by an earlier one during delivery', () { + late PostHogFeatureFlagsSubscription second; + subscriptions.add(listeners.add((_) => second.unsubscribe())); + final received = []; + second = listen(received); + + listeners.deliver(loaded('a')); + + expect(received, isEmpty); + }); +} diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index 8202fa0b..cf92812f 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -5,6 +5,7 @@ import 'dart:ui'; import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/src/error_tracking/posthog_error_tracking_autocapture_integration.dart'; import 'package:posthog_flutter/src/feature_flag_result.dart'; +import 'package:posthog_flutter/src/feature_flags_listeners.dart'; import 'package:posthog_flutter/src/feature_flags_loaded.dart'; import 'package:posthog_flutter/src/posthog_config.dart'; import 'package:posthog_flutter/src/posthog_desktop_app_info.dart'; @@ -1913,6 +1914,57 @@ void main() { expect(announced.last.errorsLoading, isTrue); }); + test('onFeatureFlags listeners receive each load', () async { + final server = await LocalPostHogServer.start(); + server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + final platform = + await setUpPlatform(configFor(server)..preloadFeatureFlags = false); + + await platform.reloadFeatureFlags(); + + expect(received.last.variants, {'variant-flag': 'test-variant'}); + }); + + test( + 'onFeatureFlags listeners get the load when the config callback throws', + () async { + final server = await LocalPostHogServer.start(); + server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); + FeatureFlagsListeners.instance.clear(); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + final platform = await setUpPlatform(configFor(server) + ..preloadFeatureFlags = false + ..onFeatureFlags = (_) => throw StateError('app callback')); + + await platform.reloadFeatureFlags(); + + expect(received.last.variants, {'variant-flag': 'test-variant'}); + }); + + test('a reset started by the config callback is not undone by the load', + () async { + final server = await LocalPostHogServer.start(); + server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); + FeatureFlagsListeners.instance.clear(); + // What reset() and close() do before reaching the platform. + final platform = await setUpPlatform(configFor(server) + ..preloadFeatureFlags = false + ..onFeatureFlags = (_) => FeatureFlagsListeners.instance.clear()); + + await platform.reloadFeatureFlags(); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + await Future.delayed(Duration.zero); + + expect(received, isEmpty); + }); + test('onFeatureFlags reports the flags cached by the previous launch', () async { final server = await LocalPostHogServer.start(); diff --git a/posthog_flutter/test/posthog_flutter_io_test.dart b/posthog_flutter/test/posthog_flutter_io_test.dart index 6b29ef44..5451fde6 100644 --- a/posthog_flutter/test/posthog_flutter_io_test.dart +++ b/posthog_flutter/test/posthog_flutter_io_test.dart @@ -1,13 +1,17 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/src/feature_flag_result.dart'; import 'package:posthog_flutter/src/logs/posthog_log_severity.dart'; +import 'package:posthog_flutter/src/feature_flags_listeners.dart'; import 'package:posthog_flutter/src/feature_flags_loaded.dart'; import 'package:posthog_flutter/src/posthog_config.dart'; import 'package:posthog_flutter/src/posthog_event.dart'; +import 'package:posthog_flutter/src/posthog.dart'; import 'package:posthog_flutter/src/posthog_flutter_io.dart'; +import 'package:posthog_flutter/src/posthog_flutter_platform_interface.dart'; import 'package:posthog_flutter/src/posthog_internal_events.dart'; // Simplified void callback for feature flags @@ -159,6 +163,14 @@ void main() { }); group('PosthogFlutterIO onFeatureFlags via setup', () { + // The native SDKs tag each load with the generation they last got. + Map nativeFlagsArgs(Map args, + {int? generation}) => + { + ...args, + 'generation': generation ?? FeatureFlagsListeners.instance.generation, + }; + test( 'setup initializes method call handler and registers callback if provided', () async { @@ -178,7 +190,7 @@ void main() { .handlePlatformMessage( channel.name, channel.codec.encodeMethodCall( - const MethodCall('onFeatureFlagsCallback', {}), + MethodCall('onFeatureFlagsCallback', nativeFlagsArgs({})), ), (ByteData? data) {}, ); @@ -221,12 +233,12 @@ void main() { await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .handlePlatformMessage( channel.name, - channel.codec.encodeMethodCall(const MethodCall( + channel.codec.encodeMethodCall(MethodCall( 'onFeatureFlagsCallback', - { + nativeFlagsArgs({ 'variants': {'on': true, 'variant': 'test', 'off': false}, 'errorsLoading': true, - }, + }), )), (ByteData? data) {}, ); @@ -246,7 +258,8 @@ void main() { .handlePlatformMessage( channel.name, channel.codec.encodeMethodCall( - const MethodCall('onFeatureFlagsCallback', {'variants': 123}), + MethodCall( + 'onFeatureFlagsCallback', nativeFlagsArgs({'variants': 123})), ), (ByteData? data) {}, ); @@ -254,6 +267,166 @@ void main() { expect(received.single.variants, isEmpty); }); + test('passes the native flags to onFeatureFlags listeners', () async { + FeatureFlagsListeners.instance.clear(); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + await posthogFlutterIO.setup(PostHogConfig('test_project_token')); + + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(MethodCall( + 'onFeatureFlagsCallback', + nativeFlagsArgs({ + 'variants': {'on': true}, + 'errorsLoading': false, + }), + )), + (ByteData? data) {}, + ); + + expect(received.single.variants, {'on': true}); + }); + + Future sendNativeFlags({int? generation}) => + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(MethodCall( + 'onFeatureFlagsCallback', + nativeFlagsArgs({ + 'variants': {'old-user-only': true}, + 'errorsLoading': false, + }, generation: generation), + )), + (ByteData? data) {}, + ); + + for (final (name, transition) in <(String, Future Function())>[ + ('reset', () => Posthog().reset()), + ('close', () => Posthog().close()), + ]) { + test('drops a native load sent before the $name reached native', + () async { + PosthogFlutterPlatformInterface.instance = posthogFlutterIO; + final received = []; + await posthogFlutterIO.setup( + PostHogConfig('test_project_token', onFeatureFlags: received.add)); + final before = FeatureFlagsListeners.instance.generation; + + await transition(); + await sendNativeFlags(generation: before); + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + await Future.delayed(Duration.zero); + + expect(received, isEmpty); + expect((log.last.arguments as Map)['flagsGeneration'], + FeatureFlagsListeners.instance.generation); + }); + } + + for (final (name, transition) in <(String, Future Function())>[ + ('reset', () => Posthog().reset()), + ('close', () => Posthog().close()), + ]) { + test('a $name started by the config callback is not undone by the load', + () async { + FeatureFlagsListeners.instance.clear(); + PosthogFlutterPlatformInterface.instance = posthogFlutterIO; + Future? pending; + await posthogFlutterIO.setup(PostHogConfig('test_project_token', + onFeatureFlags: (_) => pending ??= transition())); + + await sendNativeFlags(); + await pending; + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + await Future.delayed(Duration.zero); + + expect(received, isEmpty); + }); + } + + test( + 'onFeatureFlags listeners get the load when the config callback throws', + () async { + FeatureFlagsListeners.instance.clear(); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + await posthogFlutterIO.setup(PostHogConfig('test_project_token', + onFeatureFlags: (_) => throw StateError('app callback'))); + + await sendNativeFlags(); + + expect(received.single.variants, {'old-user-only': true}); + }); + + void mockReload(Future Function() reload) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + log.add(call); + return call.method == 'reloadFeatureFlags' ? reload() : null; + }); + } + + const reloaded = { + 'variants': {'manual': true}, + 'errorsLoading': false, + }; + + test('Android reload result reaches active and later listeners', () async { + FeatureFlagsListeners.instance.clear(); + mockReload(() async => reloaded); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + + await posthogFlutterIO.reloadFeatureFlags(); + final late = []; + final lateSubscription = FeatureFlagsListeners.instance.add(late.add); + addTearDown(lateSubscription.unsubscribe); + await pumpEventQueue(); + + expect(received.single.variants, {'manual': true}); + expect(late.single.variants, {'manual': true}); + }); + + test('iOS reload result is left to the native notification', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + FeatureFlagsListeners.instance.clear(); + mockReload(() async => reloaded); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + + await posthogFlutterIO.reloadFeatureFlags(); + + expect(received, isEmpty); + }); + + test('drops an Android reload result that finishes after a reset', + () async { + PosthogFlutterPlatformInterface.instance = posthogFlutterIO; + final reply = Completer(); + mockReload(() => reply.future); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + + final reload = posthogFlutterIO.reloadFeatureFlags(); + await Posthog().reset(); + reply.complete(reloaded); + await reload; + + expect(received, isEmpty); + }); + test('does not invoke callback when no callback is registered', () async { // Setup without callback testConfig = PostHogConfig('test_project_token'); @@ -264,7 +437,7 @@ void main() { .handlePlatformMessage( channel.name, channel.codec.encodeMethodCall( - const MethodCall('onFeatureFlagsCallback', {}), + MethodCall('onFeatureFlagsCallback', nativeFlagsArgs({})), ), (ByteData? data) => reply = data, ); diff --git a/posthog_flutter/test/posthog_flutter_web_setup_test.dart b/posthog_flutter/test/posthog_flutter_web_setup_test.dart index 718ad35b..4ef0f1b4 100644 --- a/posthog_flutter/test/posthog_flutter_web_setup_test.dart +++ b/posthog_flutter/test/posthog_flutter_web_setup_test.dart @@ -2,10 +2,13 @@ library; import 'dart:async'; +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/posthog_flutter.dart'; import 'package:posthog_flutter/posthog_flutter_web.dart'; +import 'package:posthog_flutter/src/feature_flags_listeners.dart'; import 'package:posthog_flutter/src/replay/web/web_canvas_mask_provider.dart'; /// posthog-js is initialized by the host app on web, so options the plugin @@ -63,4 +66,142 @@ void main() { expect(logs.where((line) => line.contains('evaluationContexts')), hasLength(1)); }); + + test('delivers posthog-js flag loads to onFeatureFlags listeners once', + () async { + final listeners = []; + globalContext.setProperty( + 'posthog'.toJS, + JSObject() + ..setProperty( + 'onFeatureFlags'.toJS, + ((JSFunction callback) { + listeners.add(callback); + return (() => listeners.remove(callback)).toJS; + }).toJS, + )); + addTearDown(() => globalContext.delete('posthog'.toJS)); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + final web = PosthogFlutterWeb(); + + await web.setup(PostHogConfig('test_project_token')); + await web.setup(PostHogConfig('test_project_token')); + for (final listener in listeners.toList()) { + listener.callAsFunction(null, ['on'].jsify(), {'on': true}.jsify(), null); + } + + expect(received.single.variants, {'on': true}); + }); + + List stubPosthogJs() { + final listeners = []; + globalContext.setProperty( + 'posthog'.toJS, + JSObject() + ..setProperty( + 'onFeatureFlags'.toJS, + ((JSFunction callback) { + listeners.add(callback); + return (() => listeners.remove(callback)).toJS; + }).toJS, + )); + addTearDown(() => globalContext.delete('posthog'.toJS)); + return listeners; + } + + void loadFlags(List listeners) { + for (final listener in listeners.toList()) { + listener.callAsFunction(null, ['on'].jsify(), {'on': true}.jsify(), null); + } + } + + test('onFeatureFlags listeners get the load when the config callback throws', + () async { + final listeners = stubPosthogJs(); + FeatureFlagsListeners.instance.clear(); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + + await PosthogFlutterWeb().setup(PostHogConfig('test_project_token', + onFeatureFlags: (_) => throw StateError('app callback'))); + try { + loadFlags(listeners); + } catch (_) {} + + expect(received.single.variants, {'on': true}); + }); + + test('a reset started by the config callback is not undone by the load', + () async { + final listeners = stubPosthogJs(); + FeatureFlagsListeners.instance.clear(); + + // What reset() and close() do before reaching the platform. + await PosthogFlutterWeb().setup(PostHogConfig('test_project_token', + onFeatureFlags: (_) => FeatureFlagsListeners.instance.clear())); + loadFlags(listeners); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + await Future.delayed(Duration.zero); + + expect(received, isEmpty); + }); + + test('close() during the synchronous flag replay removes the registration', + () async { + final listeners = []; + globalContext.setProperty( + 'posthog'.toJS, + JSObject() + ..setProperty( + 'onFeatureFlags'.toJS, + ((JSFunction callback) { + listeners.add(callback); + // posthog-js calls back right away when flags are loaded. + callback.callAsFunction( + null, ['on'].jsify(), {'on': true}.jsify(), null); + return (() => listeners.remove(callback)).toJS; + }).toJS, + )); + addTearDown(() => globalContext.delete('posthog'.toJS)); + final web = PosthogFlutterWeb(); + + await web.setup(PostHogConfig('test_project_token', + onFeatureFlags: (_) => web.close())); + + expect(listeners, isEmpty); + }); + + test('registrations queued by the posthog-js loader stop after close()', + () async { + // The loader snippet queues calls and returns undefined until it loads. + final queued = []; + globalContext.setProperty( + 'posthog'.toJS, + JSObject() + ..setProperty( + 'onFeatureFlags'.toJS, + ((JSFunction callback) { + queued.add(callback); + }).toJS, + )); + addTearDown(() => globalContext.delete('posthog'.toJS)); + FeatureFlagsListeners.instance.clear(); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + final web = PosthogFlutterWeb(); + + await web.setup(PostHogConfig('test_project_token')); + await web.setup(PostHogConfig('test_project_token')); + loadFlags(queued); + await web.close(); + loadFlags(queued); + + expect(received.single.variants, {'on': true}); + }); } diff --git a/posthog_flutter/test/posthog_test.dart b/posthog_flutter/test/posthog_test.dart index f175b2cc..83bccab8 100644 --- a/posthog_flutter/test/posthog_test.dart +++ b/posthog_flutter/test/posthog_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/posthog_flutter.dart'; +import 'package:posthog_flutter/src/feature_flags_listeners.dart'; import 'package:posthog_flutter/src/posthog_flutter_platform_interface.dart'; import 'package:posthog_flutter/src/posthog_internal_events.dart'; import 'package:posthog_flutter/src/replay/mask/posthog_mask_controller.dart'; @@ -44,6 +45,25 @@ void main() { }, ); + for (final entry in Function()>{ + 'reset': () => Posthog().reset(), + 'close': () => Posthog().close(), + }.entries) { + test('onFeatureFlags does not replay flags loaded before ${entry.key}', + () async { + FeatureFlagsListeners.instance.deliver(PostHogFeatureFlagsLoaded( + variants: {'flag': true}, errorsLoading: false)); + await entry.value(); + final received = []; + final subscription = Posthog().onFeatureFlags(received.add); + addTearDown(subscription.unsubscribe); + + await pumpEventQueue(); + + expect(received, isEmpty); + }); + } + test('Windows and Linux support is enabled by default', () { expect(PostHogConfig('test_project_token').enableWindowsAndLinux, isTrue); }); diff --git a/posthog_flutter/test/snapshots/setup_request.json b/posthog_flutter/test/snapshots/setup_request.json index 367138b4..53afe1ce 100644 --- a/posthog_flutter/test/snapshots/setup_request.json +++ b/posthog_flutter/test/snapshots/setup_request.json @@ -77,7 +77,8 @@ "color": "blue" } } - } + }, + "flagsGeneration": 1 } } ]