diff --git a/.changeset/v6-major-release.md b/.changeset/v6-major-release.md index 13693637..7d931704 100644 --- a/.changeset/v6-major-release.md +++ b/.changeset/v6-major-release.md @@ -29,6 +29,7 @@ - **Breaking:** send person processing as the `process_person_profile: false` option on Windows and Linux, only while it's off, instead of a `$process_person_profile` property on every event - Add an `options` parameter to `capture`, `screen`, `identify`, `setPersonProperties`, `alias`, `group` and `captureException`, and `PostHogEvent.options` for `beforeSend` callbacks - Add `captureAi` for AI observability events, a no-op that returns `null` on web +- Add `Posthog().onFeatureFlags()` to get notified after every feature flags load, returning a `PostHogFeatureFlagsSubscription` you can `unsubscribe()` - Add `PostHogConfig.captureAiCompression`, `captureAiMaxQueueSize`, `captureAiTimeout` and `captureAiMaxEventBytes` - Apply a `$process_person_profile` property or `process_person_profile` option passed with an event to that event on Windows and Linux - Back off failed sends on Windows and Linux from 1 second up to 30 seconds with ±25% jitter, instead of resending within the flush, treating `Retry-After` as a floor diff --git a/api/posthog_flutter.api.json b/api/posthog_flutter.api.json index eb616c4c..b54b0e62 100644 --- a/api/posthog_flutter.api.json +++ b/api/posthog_flutter.api.json @@ -4915,6 +4915,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, @@ -6537,6 +6559,36 @@ ], "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": [] + }, { "entryPoints": [ "posthog_flutter.dart" diff --git a/posthog_flutter/MIGRATION.md b/posthog_flutter/MIGRATION.md index ed7d4bc8..f018f017 100644 --- a/posthog_flutter/MIGRATION.md +++ b/posthog_flutter/MIGRATION.md @@ -264,6 +264,7 @@ On web, `PostHogConfig.sendFeatureFlagEvent` is not applied to posthog-js, so an - An `options` argument on `capture`, `screen`, `identify`, `setPersonProperties`, `alias`, `group` and `captureException`, and `PostHogEvent.options` in `beforeSend`. `process_person_profile`, `cookieless_mode`, `disable_skew_correction` and `product_tour_id` win over their legacy `$` properties. On web, known keys are sent as those legacy properties and other keys are dropped. - `captureAi` for `$ai_*` events. It returns the event's UUID, or `null` when the event isn't admitted. On web it sends nothing and returns `null`. +- `Posthog().onFeatureFlags()` to get notified after every feature flags load. It returns a `PostHogFeatureFlagsSubscription` you can `unsubscribe()`. ## Upgrade checklist 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 0e9add08..afe3142d 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 @@ -94,6 +94,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. @@ -278,6 +284,7 @@ class PosthogFlutterPlugin : } "reset" -> { + updateFlagsGeneration(call) reset(result) } @@ -358,6 +365,7 @@ class PosthogFlutterPlugin : } "close" -> { + updateFlagsGeneration(call) close(result) } @@ -554,6 +562,7 @@ class PosthogFlutterPlugin : return } + updateFlagsGeneration(call) setupPostHog(args) result.success(null) @@ -845,7 +854,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), + ) } } @@ -2226,6 +2238,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 e2e2308e..e65aa02d 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] { @@ -485,6 +511,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) @@ -515,6 +542,7 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { case "distinctId": distinctId(result) case "reset": + updateFlagsGeneration(call) reset(result) case "enable": enable(result) @@ -547,6 +575,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 04367702..d682724b 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -3,6 +3,7 @@ // package as the core of your plugin. import 'dart:async'; import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; import 'package:flutter/services.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; @@ -12,6 +13,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'; @@ -37,6 +39,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 @@ -149,19 +156,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(); + } } } @@ -443,7 +469,14 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { @override Future reset() async { - return _call(const MethodCall('reset')); + return _order.run(() { + // Posthog.reset() cleared this when the app called it, but a load can + // still arrive while the reset waits behind an async beforeSend. + // posthog-js sends no empty load on reset, so nothing else would replace + // that load for listeners added later. + FeatureFlagsListeners.instance.clear(); + return handleWebMethodCall(const MethodCall('reset')); + }); } @override @@ -599,6 +632,9 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { @override Future close() async { _currentScreen = null; + _featureFlagsUnsubscribe?.callAsFunction(); + _featureFlagsUnsubscribe = null; + _featureFlagsGeneration++; return _call(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 fd0c2a06..7e268151 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'; @@ -488,6 +489,7 @@ class Posthog { // Bumped before the platform call because native rotates inside that round // trip. PostHogInternalEvents.requestReplaySessionReset(); + FeatureFlagsListeners.instance.clear(); await _posthog.reset(); } @@ -611,6 +613,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 @@ -1137,6 +1175,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 71e4b6a4..16b6e67d 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'; @@ -177,8 +178,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 643d0de5..0791960f 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'; @@ -19,6 +20,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'; @@ -85,8 +87,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. @@ -203,6 +216,9 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { SurveyService().hideSurvey(); } + Map _flagsGenerationArguments() => + {'flagsGeneration': FeatureFlagsListeners.instance.generation}; + /// Flutter to Native Calls /// @override @@ -222,6 +238,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { await _invoke('setup', { ...config.toMap(), ...flutterVersionSetupArguments(), + ..._flagsGenerationArguments(), }); } on PlatformException catch (exception) { printIfDebug('Exeption on setup: $exception'); @@ -604,7 +621,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _invoke('reset'); + await _invoke('reset', _flagsGenerationArguments()); } on PlatformException catch (exception) { printIfDebug('Exeption on reset: $exception'); } @@ -688,8 +705,17 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { return featureFlagsLoadedFromMap(null); } + final generation = FeatureFlagsListeners.instance.generation; try { - return featureFlagsLoadedFromMap(await _invoke('reloadFeatureFlags')); + final loaded = + featureFlagsLoadedFromMap(await _invoke('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); @@ -1000,7 +1026,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - return await _invoke('close'); + return await _invoke('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 0eb44ccd..ce7f657d 100644 --- a/posthog_flutter/lib/src/posthog_flutter_web_handler.dart +++ b/posthog_flutter/lib/src/posthog_flutter_web_handler.dart @@ -67,7 +67,8 @@ extension PostHogExtension on PostHog { external void unregister(JSAny key); // ignore: non_constant_identifier_names external JSAny? get_session_id(); - external JSFunction onFeatureFlags(JSFunction callback); + // The loader snippet's stub returns undefined until posthog-js loads. + external JSFunction? onFeatureFlags(JSFunction callback); external void startSessionRecording(); external void stopSessionRecording(); external bool sessionRecordingStarted(); @@ -283,7 +284,7 @@ Future> _reloadFeatureFlags(PostHog? ph) async { onTimeout: () => currentFlags(errorsLoading: true), ); } finally { - unsubscribe.callAsFunction(); + unsubscribe?.callAsFunction(); } } diff --git a/posthog_flutter/test/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 9870e849..bbb21ae5 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'; @@ -2436,6 +2437,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 be167c1d..7faf138e 100644 --- a/posthog_flutter/test/posthog_flutter_io_test.dart +++ b/posthog_flutter/test/posthog_flutter_io_test.dart @@ -5,10 +5,13 @@ 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 @@ -189,6 +192,14 @@ isolate_dso_base: 7000000000 }); 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 { @@ -208,7 +219,7 @@ isolate_dso_base: 7000000000 .handlePlatformMessage( channel.name, channel.codec.encodeMethodCall( - const MethodCall('onFeatureFlagsCallback', {}), + MethodCall('onFeatureFlagsCallback', nativeFlagsArgs({})), ), (ByteData? data) {}, ); @@ -251,12 +262,12 @@ isolate_dso_base: 7000000000 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) {}, ); @@ -276,7 +287,8 @@ isolate_dso_base: 7000000000 .handlePlatformMessage( channel.name, channel.codec.encodeMethodCall( - const MethodCall('onFeatureFlagsCallback', {'variants': 123}), + MethodCall( + 'onFeatureFlagsCallback', nativeFlagsArgs({'variants': 123})), ), (ByteData? data) {}, ); @@ -284,6 +296,166 @@ isolate_dso_base: 7000000000 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'); @@ -294,7 +466,7 @@ isolate_dso_base: 7000000000 .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..ded15556 100644 --- a/posthog_flutter/test/posthog_flutter_web_setup_test.dart +++ b/posthog_flutter/test/posthog_flutter_web_setup_test.dart @@ -2,10 +2,14 @@ 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/posthog_flutter_platform_interface.dart'; import 'package:posthog_flutter/src/replay/web/web_canvas_mask_provider.dart'; /// posthog-js is initialized by the host app on web, so options the plugin @@ -63,4 +67,200 @@ 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); + } + } + + group('a load arriving while reset waits behind an async beforeSend', () { + late List listeners; + late List calls; + late Completer release; + late Future resetting; + + setUp(() async { + FeatureFlagsListeners.instance.clear(); + listeners = stubPosthogJs(); + calls = []; + globalContext.getProperty('posthog'.toJS) + ..setProperty( + 'capture'.toJS, + ((JSString event, [JSAny? a, JSAny? b]) => calls.add('capture')).toJS, + ) + ..setProperty('reset'.toJS, (() => calls.add('reset')).toJS); + release = Completer(); + final web = PosthogFlutterWeb(); + PosthogFlutterPlatformInterface.instance = web; + await web.setup(PostHogConfig('test_project_token', beforeSend: [ + (event) async { + await release.future; + return event; + }, + ])); + + final capturing = Posthog().capture(eventName: 'Logout clicked'); + resetting = Future.wait([capturing, Posthog().reset()]); + await Future.delayed(Duration.zero); + loadFlags(listeners); + expect(calls, isEmpty); + release.complete(); + await resetting; + expect(calls, ['capture', 'reset']); + }); + + test('is not replayed to a listener added after reset completes', () async { + final received = []; + final subscription = Posthog().onFeatureFlags(received.add); + addTearDown(subscription.unsubscribe); + + await Future.delayed(Duration.zero); + + expect(received, isEmpty); + }); + + test('does not stop the next load replaying', () async { + loadFlags(listeners); + final received = []; + final subscription = Posthog().onFeatureFlags(received.add); + addTearDown(subscription.unsubscribe); + + await Future.delayed(Duration.zero); + + expect(received.single.variants, {'on': true}); + }); + }); + + test('onFeatureFlags listeners get the load when the config callback throws', + () async { + final listeners = stubPosthogJs(); + 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 0f6d6f66..75b2751b 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 } } ]