From 4220eb02555b1378eaaf9f567c75866be24475c2 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Sun, 25 Oct 2026 12:45:37 -0400 Subject: [PATCH 1/7] feat: add an onFeatureFlags listener you can unsubscribe --- .changeset/v6-on-feature-flags-listener.md | 5 ++ posthog_flutter/lib/posthog_flutter_web.dart | 18 ++-- .../lib/src/feature_flags_listeners.dart | 45 ++++++++++ .../lib/src/feature_flags_loaded.dart | 17 ++++ posthog_flutter/lib/src/posthog.dart | 35 ++++++++ .../lib/src/posthog_flutter_desktop.dart | 8 +- .../lib/src/posthog_flutter_io.dart | 6 +- .../test/feature_flags_listeners_test.dart | 83 +++++++++++++++++++ .../test/posthog_flutter_desktop_test.dart | 15 ++++ .../test/posthog_flutter_io_test.dart | 24 ++++++ .../test/posthog_flutter_web_setup_test.dart | 31 +++++++ posthog_flutter/test/posthog_test.dart | 20 +++++ 12 files changed, 298 insertions(+), 9 deletions(-) create mode 100644 .changeset/v6-on-feature-flags-listener.md create mode 100644 posthog_flutter/lib/src/feature_flags_listeners.dart create mode 100644 posthog_flutter/test/feature_flags_listeners_test.dart diff --git a/.changeset/v6-on-feature-flags-listener.md b/.changeset/v6-on-feature-flags-listener.md new file mode 100644 index 00000000..44d2f2e0 --- /dev/null +++ b/.changeset/v6-on-feature-flags-listener.md @@ -0,0 +1,5 @@ +--- +"posthog_flutter": minor +--- + +- Add `Posthog().onFeatureFlags()` to get notified after every feature flags load, returning a `PostHogFeatureFlagsSubscription` you can `unsubscribe()` diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index 3f09dbfb..cc6fe120 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -11,6 +11,7 @@ import 'package:posthog_flutter/src/utils/channel_serialization.dart'; import 'package:posthog_flutter/src/utils/property_normalizer.dart'; import 'src/feature_flag_result.dart'; +import 'src/feature_flags_listeners.dart'; import 'src/feature_flags_loaded.dart'; import 'src/logs/posthog_log_severity.dart'; import 'src/posthog_config.dart'; @@ -34,6 +35,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { /// Copied at setup. Event beforeSend runs here, before posthog-js, the same /// way [PosthogFlutterIO] runs it before the platform channel. List _beforeSendCallbacks = []; + JSFunction? _featureFlagsUnsubscribe; // posthog-js has no current screen, so the feature events get it from here // the way native SDKs stamp it: the last screen beforeSend accepted, under @@ -126,19 +128,23 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { WebCanvasMaskProvider(config).register(); - if (config.onFeatureFlags != null && ph != null) { - final dartCallback = config.onFeatureFlags!; + if (ph != null) { + final dartCallback = config.onFeatureFlags; // JS SDK calls with: (flags: string[], variants: Record, context?: {errorsLoading}) final jsCallback = (JSArray jsFlags, JSObject jsFlagVariants, [JSObject? jsContext]) { - dartCallback(featureFlagsLoadedFromMap({ + final loaded = featureFlagsLoadedFromMap({ 'variants': jsFlagVariants.dartify(), 'errorsLoading': (jsContext?.dartify() as Map?)?['errorsLoading'], - })); + }); + dartCallback?.call(loaded); + FeatureFlagsListeners.instance.deliver(loaded); }.toJS; - ph.onFeatureFlags(jsCallback); + // Repeated setup would otherwise deliver each load twice. + _featureFlagsUnsubscribe?.callAsFunction(); + _featureFlagsUnsubscribe = ph.onFeatureFlags(jsCallback); } } @@ -502,6 +508,8 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { @override Future close() async { _currentScreen = null; + _featureFlagsUnsubscribe?.callAsFunction(); + _featureFlagsUnsubscribe = null; return handleWebMethodCall(const MethodCall('close')); } diff --git a/posthog_flutter/lib/src/feature_flags_listeners.dart b/posthog_flutter/lib/src/feature_flags_listeners.dart new file mode 100644 index 00000000..9bfbf0ae --- /dev/null +++ b/posthog_flutter/lib/src/feature_flags_listeners.dart @@ -0,0 +1,45 @@ +import 'dart:async'; + +import 'package:meta/meta.dart'; + +import 'feature_flags_loaded.dart'; +import 'posthog_flutter_platform_interface.dart'; + +/// Delivers each feature flags load to the listeners added with +/// [Posthog.onFeatureFlags]. +@internal +class FeatureFlagsListeners { + FeatureFlagsListeners._(); + + static final instance = FeatureFlagsListeners._(); + + final _listeners = {}; + PostHogFeatureFlagsLoaded? _last; + + PostHogFeatureFlagsSubscription add(OnFeatureFlagsCallback listener) { + final token = Object(); + _listeners[token] = listener; + final current = _last; + if (current != null) { + // Skipped if a newer load already reached the listener. + scheduleMicrotask(() { + if (_listeners.containsKey(token) && identical(_last, current)) { + listener(current); + } + }); + } + return PostHogFeatureFlagsSubscription(() => _listeners.remove(token)); + } + + void deliver(PostHogFeatureFlagsLoaded loaded) { + _last = loaded; + for (final entry in _listeners.entries.toList()) { + // An earlier listener may have unsubscribed this one. + if (_listeners.containsKey(entry.key)) entry.value(loaded); + } + } + + /// Forgets the last load, so new listeners don't get the previous user's + /// flags after `reset()` or `close()`. + void clear() => _last = null; +} diff --git a/posthog_flutter/lib/src/feature_flags_loaded.dart b/posthog_flutter/lib/src/feature_flags_loaded.dart index 0a8c196b..8efade70 100644 --- a/posthog_flutter/lib/src/feature_flags_loaded.dart +++ b/posthog_flutter/lib/src/feature_flags_loaded.dart @@ -1,3 +1,5 @@ +import 'package:meta/meta.dart'; + /// The feature flags passed to [PostHogConfig.onFeatureFlags] and returned by /// [Posthog.reloadFeatureFlags]. /// @@ -51,3 +53,18 @@ class PostHogFeatureFlagsLoaded { String toString() => 'PostHogFeatureFlagsLoaded(variants: $variants, errorsLoading: $errorsLoading)'; } + +/// A listener added with [Posthog.onFeatureFlags]. +/// +/// Call [unsubscribe] to stop receiving feature flags updates. +class PostHogFeatureFlagsSubscription { + final void Function() _unsubscribe; + + /// Not part of the public API. + @internal + PostHogFeatureFlagsSubscription(this._unsubscribe); + + /// Stops the listener from receiving further updates. Calling it again has + /// no effect. + void unsubscribe() => _unsubscribe(); +} diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index d45b5699..d08213a6 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -5,6 +5,7 @@ import 'package:meta/meta.dart'; import 'package:posthog_flutter/src/error_tracking/posthog_error_tracking_autocapture_integration.dart'; import 'package:posthog_flutter/src/error_tracking/posthog_exception.dart'; import 'feature_flag_result.dart'; +import 'feature_flags_listeners.dart'; import 'feature_flags_loaded.dart'; import 'logs/posthog_log_record.dart'; import 'logs/posthog_log_severity.dart'; @@ -389,6 +390,7 @@ class Posthog { // Bumped before the platform call because native rotates inside that round // trip. PostHogInternalEvents.requestReplaySessionReset(); + FeatureFlagsListeners.instance.clear(); await _posthog.reset(); } @@ -515,6 +517,38 @@ class Posthog { Future reloadFeatureFlags() => _posthog.reloadFeatureFlags(); + /// Registers a [callback] that's invoked whenever feature flags are loaded + /// or updated. + /// + /// It's invoked after the SDK loads flags at startup (including from + /// bootstrap values), after [reloadFeatureFlags], and after calls that + /// reload flags such as [identify], [group] or [reset]. If a load fails, + /// [PostHogFeatureFlagsLoaded.errorsLoading] is `true` and the values are + /// the last known flags. If flags have already loaded when you register, + /// it's also invoked with the current values shortly afterwards. + /// + /// You can register before [setup], and the callback stays registered + /// across [close] and [setup]. Treat each invocation as "flags may have + /// changed", not as an exactly-once event. + /// + /// Returns a subscription; call + /// [PostHogFeatureFlagsSubscription.unsubscribe] to stop receiving updates. + /// + /// **Example:** + /// ```dart + /// final subscription = Posthog().onFeatureFlags((loaded) { + /// if (loaded.variants['new-checkout'] == 'test') { + /// // Show the test variant. + /// } + /// }); + /// + /// // Later, stop listening. + /// subscription.unsubscribe(); + /// ``` + PostHogFeatureFlagsSubscription onFeatureFlags( + OnFeatureFlagsCallback callback) => + FeatureFlagsListeners.instance.add(callback); + /// Sets person properties that are used only for feature flag evaluation. /// /// Docs: https://posthog.com/docs/feature-flags @@ -1032,6 +1066,7 @@ class Posthog { // recording that follows must start clean either way. PostHogInternalEvents.requestReplaySessionReset(); PosthogObserver.clearCurrentContext(); + FeatureFlagsListeners.instance.clear(); // Uninstall Flutter integrations _uninstallFlutterIntegrations(); diff --git a/posthog_flutter/lib/src/posthog_flutter_desktop.dart b/posthog_flutter/lib/src/posthog_flutter_desktop.dart index 919d6064..ba6ab89d 100644 --- a/posthog_flutter/lib/src/posthog_flutter_desktop.dart +++ b/posthog_flutter/lib/src/posthog_flutter_desktop.dart @@ -6,6 +6,7 @@ import 'core/file_storage.dart'; import 'core/utils/utils.dart'; import 'error_tracking/dart_exception_processor.dart'; import 'feature_flag_result.dart'; +import 'feature_flags_listeners.dart'; import 'feature_flags_loaded.dart'; import 'logs/posthog_log_severity.dart'; import 'posthog_config.dart'; @@ -176,8 +177,11 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { projectToken: config.projectToken, host: removeTrailingSlash(config.host), ); - _featureFlagsUnsubscribe = client.onFeatureFlags( - () => _config?.onFeatureFlags?.call(client.featureFlagsLoaded())); + _featureFlagsUnsubscribe = client.onFeatureFlags(() { + final loaded = client.featureFlagsLoaded(); + _config?.onFeatureFlags?.call(loaded); + FeatureFlagsListeners.instance.deliver(loaded); + }); final stepsConfig = config.errorTrackingConfig.exceptionSteps; _exceptionSteps = stepsConfig.enabled diff --git a/posthog_flutter/lib/src/posthog_flutter_io.dart b/posthog_flutter/lib/src/posthog_flutter_io.dart index 1ee6368c..094b3e98 100644 --- a/posthog_flutter/lib/src/posthog_flutter_io.dart +++ b/posthog_flutter/lib/src/posthog_flutter_io.dart @@ -17,6 +17,7 @@ import 'utils/flutter_version.dart'; import 'utils/property_normalizer.dart'; import 'feature_flag_result.dart'; +import 'feature_flags_listeners.dart'; import 'feature_flags_loaded.dart'; import 'logs/posthog_log_severity.dart'; import 'posthog_config.dart'; @@ -74,8 +75,9 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { await cleanupSurveys(); return null; case 'onFeatureFlagsCallback': - _onFeatureFlagsCallback - ?.call(featureFlagsLoadedFromMap(call.arguments)); + final loaded = featureFlagsLoadedFromMap(call.arguments); + _onFeatureFlagsCallback?.call(loaded); + FeatureFlagsListeners.instance.deliver(loaded); break; case 'pushIdentityProvider': // Returning a value here is the reply the native SDK awaits. diff --git a/posthog_flutter/test/feature_flags_listeners_test.dart b/posthog_flutter/test/feature_flags_listeners_test.dart new file mode 100644 index 00000000..aa318590 --- /dev/null +++ b/posthog_flutter/test/feature_flags_listeners_test.dart @@ -0,0 +1,83 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/feature_flags_listeners.dart'; +import 'package:posthog_flutter/src/feature_flags_loaded.dart'; + +PostHogFeatureFlagsLoaded loaded(String variant) => PostHogFeatureFlagsLoaded( + variants: {'flag': variant}, errorsLoading: false); + +void main() { + final listeners = FeatureFlagsListeners.instance; + final subscriptions = []; + + PostHogFeatureFlagsSubscription listen( + List received) { + final subscription = listeners.add(received.add); + subscriptions.add(subscription); + return subscription; + } + + tearDown(() { + for (final subscription in subscriptions) { + subscription.unsubscribe(); + } + subscriptions.clear(); + listeners.clear(); + }); + + test('delivers each load until unsubscribed', () { + final received = []; + final subscription = listen(received); + final first = loaded('a'); + + listeners.deliver(first); + subscription.unsubscribe(); + listeners.deliver(loaded('b')); + + expect(received, [first]); + }); + + test('replays the last load to a new listener once', () async { + final last = loaded('a'); + listeners.deliver(last); + final received = []; + listen(received); + + await pumpEventQueue(); + + expect(received, [last]); + }); + + test('skips the replay when a newer load arrives first', () async { + listeners.deliver(loaded('a')); + final received = []; + listen(received); + final newer = loaded('b'); + listeners.deliver(newer); + + await pumpEventQueue(); + + expect(received, [newer]); + }); + + test('does not replay after clear', () async { + listeners.deliver(loaded('a')); + listeners.clear(); + final received = []; + listen(received); + + await pumpEventQueue(); + + expect(received, isEmpty); + }); + + test('skips a listener unsubscribed by an earlier one during delivery', () { + late PostHogFeatureFlagsSubscription second; + subscriptions.add(listeners.add((_) => second.unsubscribe())); + final received = []; + second = listen(received); + + listeners.deliver(loaded('a')); + + expect(received, isEmpty); + }); +} diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index 6a532057..1c33e543 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -5,6 +5,7 @@ import 'dart:ui'; import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/src/error_tracking/posthog_error_tracking_autocapture_integration.dart'; import 'package:posthog_flutter/src/feature_flag_result.dart'; +import 'package:posthog_flutter/src/feature_flags_listeners.dart'; import 'package:posthog_flutter/src/feature_flags_loaded.dart'; import 'package:posthog_flutter/src/posthog_config.dart'; import 'package:posthog_flutter/src/posthog_desktop_app_info.dart'; @@ -1940,6 +1941,20 @@ void main() { expect(announced.last.errorsLoading, isTrue); }); + test('onFeatureFlags listeners receive each load', () async { + final server = await LocalPostHogServer.start(); + server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + final platform = + await setUpPlatform(configFor(server)..preloadFeatureFlags = false); + + await platform.reloadFeatureFlags(); + + expect(received.last.variants, {'variant-flag': 'test-variant'}); + }); + test('onFeatureFlags reports the flags cached by the previous launch', () async { final server = await LocalPostHogServer.start(); diff --git a/posthog_flutter/test/posthog_flutter_io_test.dart b/posthog_flutter/test/posthog_flutter_io_test.dart index 33a514fa..adc741b1 100644 --- a/posthog_flutter/test/posthog_flutter_io_test.dart +++ b/posthog_flutter/test/posthog_flutter_io_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/src/feature_flag_result.dart'; import 'package:posthog_flutter/src/logs/posthog_log_severity.dart'; +import 'package:posthog_flutter/src/feature_flags_listeners.dart'; import 'package:posthog_flutter/src/feature_flags_loaded.dart'; import 'package:posthog_flutter/src/posthog_config.dart'; import 'package:posthog_flutter/src/posthog_event.dart'; @@ -254,6 +255,29 @@ void main() { expect(received.single.variants, isEmpty); }); + test('passes the native flags to onFeatureFlags listeners', () async { + FeatureFlagsListeners.instance.clear(); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + await posthogFlutterIO.setup(PostHogConfig('test_project_token')); + + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(const MethodCall( + 'onFeatureFlagsCallback', + { + 'variants': {'on': true}, + 'errorsLoading': false, + }, + )), + (ByteData? data) {}, + ); + + expect(received.single.variants, {'on': true}); + }); + test('does not invoke callback when no callback is registered', () async { // Setup without callback testConfig = PostHogConfig('test_project_token'); diff --git a/posthog_flutter/test/posthog_flutter_web_setup_test.dart b/posthog_flutter/test/posthog_flutter_web_setup_test.dart index 718ad35b..fec61462 100644 --- a/posthog_flutter/test/posthog_flutter_web_setup_test.dart +++ b/posthog_flutter/test/posthog_flutter_web_setup_test.dart @@ -2,10 +2,13 @@ library; import 'dart:async'; +import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/posthog_flutter.dart'; import 'package:posthog_flutter/posthog_flutter_web.dart'; +import 'package:posthog_flutter/src/feature_flags_listeners.dart'; import 'package:posthog_flutter/src/replay/web/web_canvas_mask_provider.dart'; /// posthog-js is initialized by the host app on web, so options the plugin @@ -63,4 +66,32 @@ void main() { expect(logs.where((line) => line.contains('evaluationContexts')), hasLength(1)); }); + + test('delivers posthog-js flag loads to onFeatureFlags listeners once', + () async { + final listeners = []; + globalContext.setProperty( + 'posthog'.toJS, + JSObject() + ..setProperty( + 'onFeatureFlags'.toJS, + ((JSFunction callback) { + listeners.add(callback); + return (() => listeners.remove(callback)).toJS; + }).toJS, + )); + addTearDown(() => globalContext.delete('posthog'.toJS)); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + final web = PosthogFlutterWeb(); + + await web.setup(PostHogConfig('test_project_token')); + await web.setup(PostHogConfig('test_project_token')); + for (final listener in listeners.toList()) { + listener.callAsFunction(null, ['on'].jsify(), {'on': true}.jsify(), null); + } + + expect(received.single.variants, {'on': true}); + }); } diff --git a/posthog_flutter/test/posthog_test.dart b/posthog_flutter/test/posthog_test.dart index 7b229e2a..7afa4a46 100644 --- a/posthog_flutter/test/posthog_test.dart +++ b/posthog_flutter/test/posthog_test.dart @@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/posthog_flutter.dart'; +import 'package:posthog_flutter/src/feature_flags_listeners.dart'; import 'package:posthog_flutter/src/posthog_flutter_platform_interface.dart'; import 'package:posthog_flutter/src/posthog_internal_events.dart'; import 'package:posthog_flutter/src/replay/mask/posthog_mask_controller.dart'; @@ -44,6 +45,25 @@ void main() { }, ); + for (final entry in Function()>{ + 'reset': () => Posthog().reset(), + 'close': () => Posthog().close(), + }.entries) { + test('onFeatureFlags does not replay flags loaded before ${entry.key}', + () async { + FeatureFlagsListeners.instance.deliver(PostHogFeatureFlagsLoaded( + variants: {'flag': true}, errorsLoading: false)); + await entry.value(); + final received = []; + final subscription = Posthog().onFeatureFlags(received.add); + addTearDown(subscription.unsubscribe); + + await pumpEventQueue(); + + expect(received, isEmpty); + }); + } + test('Windows and Linux support is enabled by default', () { expect(PostHogConfig('test_project_token').enableWindowsAndLinux, isTrue); }); From 0677b43a354826a2c11e67af6d2303314a7746a2 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Sun, 25 Oct 2026 12:57:06 -0400 Subject: [PATCH 2/7] fix: isolate failing flag listeners, skip web registration without onFeatureFlags, update API snapshot --- api/posthog_flutter.api.json | 52 +++++++++++++++++++ posthog_flutter/lib/posthog_flutter_web.dart | 4 +- .../lib/src/feature_flags_listeners.dart | 15 +++++- .../test/feature_flags_listeners_test.dart | 11 ++++ 4 files changed, 79 insertions(+), 3 deletions(-) diff --git a/api/posthog_flutter.api.json b/api/posthog_flutter.api.json index 71c718a4..8f110af3 100644 --- a/api/posthog_flutter.api.json +++ b/api/posthog_flutter.api.json @@ -4526,6 +4526,28 @@ "type": "method", "typeParameterNames": [] }, + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isStatic": false, + "name": "onFeatureFlags", + "parameters": [ + { + "isDeprecated": false, + "isExperimental": false, + "isNamed": false, + "isRequired": true, + "name": "callback", + "relativePath": "lib/src/posthog.dart", + "typeName": "void Function(PostHogFeatureFlagsLoaded)" + } + ], + "relativePath": "lib/src/posthog.dart", + "returnTypeName": "PostHogFeatureFlagsSubscription", + "type": "method", + "typeParameterNames": [] + }, { "entryPoints": [], "isDeprecated": false, @@ -6109,6 +6131,36 @@ "Object" ], "typeParameterNames": [] + }, + { + "entryPoints": [ + "posthog_flutter.dart" + ], + "executableDeclarations": [ + { + "entryPoints": [], + "isDeprecated": false, + "isExperimental": false, + "isStatic": false, + "name": "unsubscribe", + "parameters": [], + "relativePath": "lib/src/feature_flags_loaded.dart", + "returnTypeName": "void", + "type": "method", + "typeParameterNames": [] + } + ], + "fieldDeclarations": [], + "isDeprecated": false, + "isExperimental": false, + "isRequired": false, + "isSealed": false, + "name": "PostHogFeatureFlagsSubscription", + "relativePath": "lib/src/feature_flags_loaded.dart", + "superTypeNames": [ + "Object" + ], + "typeParameterNames": [] } ], "iosPlatformConstraints": null, diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index cc6fe120..2d7642bb 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -2,6 +2,7 @@ // of your plugin as a separate package, instead of inlining it in the same // package as the core of your plugin. import 'dart:js_interop'; +import 'dart:js_interop_unsafe'; import 'package:flutter/services.dart'; import 'package:flutter_web_plugins/flutter_web_plugins.dart'; @@ -128,7 +129,8 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { WebCanvasMaskProvider(config).register(); - if (ph != null) { + // Skipped for a posthog-js stub without onFeatureFlags. + if (ph != null && (ph as JSObject).has('onFeatureFlags')) { final dartCallback = config.onFeatureFlags; // JS SDK calls with: (flags: string[], variants: Record, context?: {errorsLoading}) diff --git a/posthog_flutter/lib/src/feature_flags_listeners.dart b/posthog_flutter/lib/src/feature_flags_listeners.dart index 9bfbf0ae..f94f299b 100644 --- a/posthog_flutter/lib/src/feature_flags_listeners.dart +++ b/posthog_flutter/lib/src/feature_flags_listeners.dart @@ -4,6 +4,7 @@ import 'package:meta/meta.dart'; import 'feature_flags_loaded.dart'; import 'posthog_flutter_platform_interface.dart'; +import 'util/logging.dart'; /// Delivers each feature flags load to the listeners added with /// [Posthog.onFeatureFlags]. @@ -24,7 +25,7 @@ class FeatureFlagsListeners { // Skipped if a newer load already reached the listener. scheduleMicrotask(() { if (_listeners.containsKey(token) && identical(_last, current)) { - listener(current); + _notify(listener, current); } }); } @@ -35,7 +36,17 @@ class FeatureFlagsListeners { _last = loaded; for (final entry in _listeners.entries.toList()) { // An earlier listener may have unsubscribed this one. - if (_listeners.containsKey(entry.key)) entry.value(loaded); + if (_listeners.containsKey(entry.key)) _notify(entry.value, loaded); + } + } + + // A throwing listener must not stop the others from getting the load. + void _notify( + OnFeatureFlagsCallback listener, PostHogFeatureFlagsLoaded loaded) { + try { + listener(loaded); + } catch (e) { + printIfDebug('onFeatureFlags listener threw: $e'); } } diff --git a/posthog_flutter/test/feature_flags_listeners_test.dart b/posthog_flutter/test/feature_flags_listeners_test.dart index aa318590..1a686ab2 100644 --- a/posthog_flutter/test/feature_flags_listeners_test.dart +++ b/posthog_flutter/test/feature_flags_listeners_test.dart @@ -70,6 +70,17 @@ void main() { expect(received, isEmpty); }); + test('delivers to the other listeners when one throws', () { + subscriptions.add(listeners.add((_) => throw StateError('listener'))); + final received = []; + listen(received); + final load = loaded('a'); + + listeners.deliver(load); + + expect(received, [load]); + }); + test('skips a listener unsubscribed by an earlier one during delivery', () { late PostHogFeatureFlagsSubscription second; subscriptions.add(listeners.add((_) => second.unsubscribe())); From b130bcf559ff3994a203ae209f4419f5bdcff989 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Tue, 27 Oct 2026 18:43:46 -0400 Subject: [PATCH 3/7] fix: deliver flag loads to onFeatureFlags listeners before the config callback A config callback that threw kept listeners from getting the load, and a reset() or close() it started was undone when the load was cached after it. Co-Authored-By: Claude Opus 5.5 --- posthog_flutter/lib/posthog_flutter_web.dart | 4 +- .../lib/src/posthog_flutter_desktop.dart | 4 +- .../lib/src/posthog_flutter_io.dart | 4 +- .../test/posthog_flutter_desktop_test.dart | 37 ++++++++++++ .../test/posthog_flutter_io_test.dart | 54 ++++++++++++++++++ .../test/posthog_flutter_web_setup_test.dart | 56 +++++++++++++++++++ 6 files changed, 156 insertions(+), 3 deletions(-) diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index 2d7642bb..7ef2a9ca 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -140,8 +140,10 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { 'variants': jsFlagVariants.dartify(), 'errorsLoading': (jsContext?.dartify() as Map?)?['errorsLoading'], }); - dartCallback?.call(loaded); + // First, so a throwing config callback can't starve listeners and a + // reset() or close() it starts clears this load. FeatureFlagsListeners.instance.deliver(loaded); + dartCallback?.call(loaded); }.toJS; // Repeated setup would otherwise deliver each load twice. diff --git a/posthog_flutter/lib/src/posthog_flutter_desktop.dart b/posthog_flutter/lib/src/posthog_flutter_desktop.dart index ba6ab89d..b8d236ac 100644 --- a/posthog_flutter/lib/src/posthog_flutter_desktop.dart +++ b/posthog_flutter/lib/src/posthog_flutter_desktop.dart @@ -179,8 +179,10 @@ class PosthogFlutterDesktop extends PosthogFlutterPlatformInterface { ); _featureFlagsUnsubscribe = client.onFeatureFlags(() { final loaded = client.featureFlagsLoaded(); - _config?.onFeatureFlags?.call(loaded); + // First, so a throwing config callback can't starve listeners and a + // reset() or close() it starts clears this load. FeatureFlagsListeners.instance.deliver(loaded); + _config?.onFeatureFlags?.call(loaded); }); final stepsConfig = config.errorTrackingConfig.exceptionSteps; diff --git a/posthog_flutter/lib/src/posthog_flutter_io.dart b/posthog_flutter/lib/src/posthog_flutter_io.dart index 094b3e98..c8550152 100644 --- a/posthog_flutter/lib/src/posthog_flutter_io.dart +++ b/posthog_flutter/lib/src/posthog_flutter_io.dart @@ -76,8 +76,10 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { return null; case 'onFeatureFlagsCallback': final loaded = featureFlagsLoadedFromMap(call.arguments); - _onFeatureFlagsCallback?.call(loaded); + // First, so a throwing config callback can't starve listeners and a + // reset() or close() it starts clears this load. FeatureFlagsListeners.instance.deliver(loaded); + _onFeatureFlagsCallback?.call(loaded); break; case 'pushIdentityProvider': // Returning a value here is the reply the native SDK awaits. diff --git a/posthog_flutter/test/posthog_flutter_desktop_test.dart b/posthog_flutter/test/posthog_flutter_desktop_test.dart index 1c33e543..32cd8004 100644 --- a/posthog_flutter/test/posthog_flutter_desktop_test.dart +++ b/posthog_flutter/test/posthog_flutter_desktop_test.dart @@ -1955,6 +1955,43 @@ void main() { expect(received.last.variants, {'variant-flag': 'test-variant'}); }); + test( + 'onFeatureFlags listeners get the load when the config callback throws', + () async { + final server = await LocalPostHogServer.start(); + server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); + FeatureFlagsListeners.instance.clear(); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + final platform = await setUpPlatform(configFor(server) + ..preloadFeatureFlags = false + ..onFeatureFlags = (_) => throw StateError('app callback')); + + await platform.reloadFeatureFlags(); + + expect(received.last.variants, {'variant-flag': 'test-variant'}); + }); + + test('a reset started by the config callback is not undone by the load', + () async { + final server = await LocalPostHogServer.start(); + server.respond = (_) => PostHogResponse.json(_variantFlagResponse()); + FeatureFlagsListeners.instance.clear(); + // What reset() and close() do before reaching the platform. + final platform = await setUpPlatform(configFor(server) + ..preloadFeatureFlags = false + ..onFeatureFlags = (_) => FeatureFlagsListeners.instance.clear()); + + await platform.reloadFeatureFlags(); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + await Future.delayed(Duration.zero); + + expect(received, isEmpty); + }); + test('onFeatureFlags reports the flags cached by the previous launch', () async { final server = await LocalPostHogServer.start(); diff --git a/posthog_flutter/test/posthog_flutter_io_test.dart b/posthog_flutter/test/posthog_flutter_io_test.dart index adc741b1..91c2fdeb 100644 --- a/posthog_flutter/test/posthog_flutter_io_test.dart +++ b/posthog_flutter/test/posthog_flutter_io_test.dart @@ -8,7 +8,9 @@ import 'package:posthog_flutter/src/feature_flags_listeners.dart'; import 'package:posthog_flutter/src/feature_flags_loaded.dart'; import 'package:posthog_flutter/src/posthog_config.dart'; import 'package:posthog_flutter/src/posthog_event.dart'; +import 'package:posthog_flutter/src/posthog.dart'; import 'package:posthog_flutter/src/posthog_flutter_io.dart'; +import 'package:posthog_flutter/src/posthog_flutter_platform_interface.dart'; import 'package:posthog_flutter/src/posthog_internal_events.dart'; // Simplified void callback for feature flags @@ -278,6 +280,58 @@ void main() { expect(received.single.variants, {'on': true}); }); + Future sendNativeFlags() => + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .handlePlatformMessage( + channel.name, + channel.codec.encodeMethodCall(const MethodCall( + 'onFeatureFlagsCallback', + { + 'variants': {'old-user-only': true}, + 'errorsLoading': false, + }, + )), + (ByteData? data) {}, + ); + + for (final (name, transition) in <(String, Future Function())>[ + ('reset', () => Posthog().reset()), + ('close', () => Posthog().close()), + ]) { + test('a $name started by the config callback is not undone by the load', + () async { + FeatureFlagsListeners.instance.clear(); + PosthogFlutterPlatformInterface.instance = posthogFlutterIO; + Future? pending; + await posthogFlutterIO.setup(PostHogConfig('test_project_token', + onFeatureFlags: (_) => pending ??= transition())); + + await sendNativeFlags(); + await pending; + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + await Future.delayed(Duration.zero); + + expect(received, isEmpty); + }); + } + + test( + 'onFeatureFlags listeners get the load when the config callback throws', + () async { + FeatureFlagsListeners.instance.clear(); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + await posthogFlutterIO.setup(PostHogConfig('test_project_token', + onFeatureFlags: (_) => throw StateError('app callback'))); + + await sendNativeFlags(); + + expect(received.single.variants, {'old-user-only': true}); + }); + test('does not invoke callback when no callback is registered', () async { // Setup without callback testConfig = PostHogConfig('test_project_token'); diff --git a/posthog_flutter/test/posthog_flutter_web_setup_test.dart b/posthog_flutter/test/posthog_flutter_web_setup_test.dart index fec61462..35ae505a 100644 --- a/posthog_flutter/test/posthog_flutter_web_setup_test.dart +++ b/posthog_flutter/test/posthog_flutter_web_setup_test.dart @@ -94,4 +94,60 @@ void main() { expect(received.single.variants, {'on': true}); }); + + List stubPosthogJs() { + final listeners = []; + globalContext.setProperty( + 'posthog'.toJS, + JSObject() + ..setProperty( + 'onFeatureFlags'.toJS, + ((JSFunction callback) { + listeners.add(callback); + return (() => listeners.remove(callback)).toJS; + }).toJS, + )); + addTearDown(() => globalContext.delete('posthog'.toJS)); + return listeners; + } + + void loadFlags(List listeners) { + for (final listener in listeners.toList()) { + listener.callAsFunction(null, ['on'].jsify(), {'on': true}.jsify(), null); + } + } + + test('onFeatureFlags listeners get the load when the config callback throws', + () async { + final listeners = stubPosthogJs(); + FeatureFlagsListeners.instance.clear(); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + + await PosthogFlutterWeb().setup(PostHogConfig('test_project_token', + onFeatureFlags: (_) => throw StateError('app callback'))); + try { + loadFlags(listeners); + } catch (_) {} + + expect(received.single.variants, {'on': true}); + }); + + test('a reset started by the config callback is not undone by the load', + () async { + final listeners = stubPosthogJs(); + FeatureFlagsListeners.instance.clear(); + + // What reset() and close() do before reaching the platform. + await PosthogFlutterWeb().setup(PostHogConfig('test_project_token', + onFeatureFlags: (_) => FeatureFlagsListeners.instance.clear())); + loadFlags(listeners); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + await Future.delayed(Duration.zero); + + expect(received, isEmpty); + }); } From 7aaa9b8c9d55650888c7b0eec69fe36278fc93d4 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 11:52:38 -0400 Subject: [PATCH 4/7] fix(web): stop flag registrations left by close() or the posthog-js loader A registration whose unsubscribe isn't stored yet (close() during the synchronous replay) or never arrives (the loader snippet's stub) now goes inert once setup() or close() runs again. Co-Authored-By: Claude Opus 5.5 --- posthog_flutter/lib/posthog_flutter_web.dart | 23 ++++++-- .../lib/src/posthog_flutter_web_handler.dart | 5 +- .../test/posthog_flutter_web_setup_test.dart | 54 +++++++++++++++++++ 3 files changed, 77 insertions(+), 5 deletions(-) diff --git a/posthog_flutter/lib/posthog_flutter_web.dart b/posthog_flutter/lib/posthog_flutter_web.dart index 7ef2a9ca..7b283306 100644 --- a/posthog_flutter/lib/posthog_flutter_web.dart +++ b/posthog_flutter/lib/posthog_flutter_web.dart @@ -38,6 +38,10 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { List _beforeSendCallbacks = []; JSFunction? _featureFlagsUnsubscribe; + /// Bumped by each setup() and close(), so a posthog-js registration from an + /// earlier one stops delivering even when it can't be unsubscribed. + int _featureFlagsGeneration = 0; + // posthog-js has no current screen, so the feature events get it from here // the way native SDKs stamp it: the last screen beforeSend accepted, under // its final name, until close(). @@ -132,10 +136,17 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { // Skipped for a posthog-js stub without onFeatureFlags. if (ph != null && (ph as JSObject).has('onFeatureFlags')) { final dartCallback = config.onFeatureFlags; + // Repeated setup would otherwise deliver each load twice. + _featureFlagsUnsubscribe?.callAsFunction(); + _featureFlagsUnsubscribe = null; + final generation = ++_featureFlagsGeneration; // JS SDK calls with: (flags: string[], variants: Record, context?: {errorsLoading}) final jsCallback = (JSArray jsFlags, JSObject jsFlagVariants, [JSObject? jsContext]) { + // The posthog-js loader snippet queues the registration and returns + // no unsubscribe, so a later setup() or close() can't remove it. + if (generation != _featureFlagsGeneration) return; final loaded = featureFlagsLoadedFromMap({ 'variants': jsFlagVariants.dartify(), 'errorsLoading': (jsContext?.dartify() as Map?)?['errorsLoading'], @@ -146,9 +157,14 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { dartCallback?.call(loaded); }.toJS; - // Repeated setup would otherwise deliver each load twice. - _featureFlagsUnsubscribe?.callAsFunction(); - _featureFlagsUnsubscribe = ph.onFeatureFlags(jsCallback); + // posthog-js replays loaded flags synchronously, and a callback may call + // close() or setup() before this returns the unsubscribe. + final unsubscribe = ph.onFeatureFlags(jsCallback); + if (generation == _featureFlagsGeneration) { + _featureFlagsUnsubscribe = unsubscribe; + } else { + unsubscribe?.callAsFunction(); + } } } @@ -514,6 +530,7 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface { _currentScreen = null; _featureFlagsUnsubscribe?.callAsFunction(); _featureFlagsUnsubscribe = null; + _featureFlagsGeneration++; return handleWebMethodCall(const MethodCall('close')); } diff --git a/posthog_flutter/lib/src/posthog_flutter_web_handler.dart b/posthog_flutter/lib/src/posthog_flutter_web_handler.dart index 0eb44ccd..ce7f657d 100644 --- a/posthog_flutter/lib/src/posthog_flutter_web_handler.dart +++ b/posthog_flutter/lib/src/posthog_flutter_web_handler.dart @@ -67,7 +67,8 @@ extension PostHogExtension on PostHog { external void unregister(JSAny key); // ignore: non_constant_identifier_names external JSAny? get_session_id(); - external JSFunction onFeatureFlags(JSFunction callback); + // The loader snippet's stub returns undefined until posthog-js loads. + external JSFunction? onFeatureFlags(JSFunction callback); external void startSessionRecording(); external void stopSessionRecording(); external bool sessionRecordingStarted(); @@ -283,7 +284,7 @@ Future> _reloadFeatureFlags(PostHog? ph) async { onTimeout: () => currentFlags(errorsLoading: true), ); } finally { - unsubscribe.callAsFunction(); + unsubscribe?.callAsFunction(); } } diff --git a/posthog_flutter/test/posthog_flutter_web_setup_test.dart b/posthog_flutter/test/posthog_flutter_web_setup_test.dart index 35ae505a..4ef0f1b4 100644 --- a/posthog_flutter/test/posthog_flutter_web_setup_test.dart +++ b/posthog_flutter/test/posthog_flutter_web_setup_test.dart @@ -150,4 +150,58 @@ void main() { expect(received, isEmpty); }); + + test('close() during the synchronous flag replay removes the registration', + () async { + final listeners = []; + globalContext.setProperty( + 'posthog'.toJS, + JSObject() + ..setProperty( + 'onFeatureFlags'.toJS, + ((JSFunction callback) { + listeners.add(callback); + // posthog-js calls back right away when flags are loaded. + callback.callAsFunction( + null, ['on'].jsify(), {'on': true}.jsify(), null); + return (() => listeners.remove(callback)).toJS; + }).toJS, + )); + addTearDown(() => globalContext.delete('posthog'.toJS)); + final web = PosthogFlutterWeb(); + + await web.setup(PostHogConfig('test_project_token', + onFeatureFlags: (_) => web.close())); + + expect(listeners, isEmpty); + }); + + test('registrations queued by the posthog-js loader stop after close()', + () async { + // The loader snippet queues calls and returns undefined until it loads. + final queued = []; + globalContext.setProperty( + 'posthog'.toJS, + JSObject() + ..setProperty( + 'onFeatureFlags'.toJS, + ((JSFunction callback) { + queued.add(callback); + }).toJS, + )); + addTearDown(() => globalContext.delete('posthog'.toJS)); + FeatureFlagsListeners.instance.clear(); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + final web = PosthogFlutterWeb(); + + await web.setup(PostHogConfig('test_project_token')); + await web.setup(PostHogConfig('test_project_token')); + loadFlags(queued); + await web.close(); + loadFlags(queued); + + expect(received.single.variants, {'on': true}); + }); } From 143199431b9dee8a22376d87d2445a2485c21185 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 11:56:25 -0400 Subject: [PATCH 5/7] fix: drop native flag loads sent before a reset or close reached native Dart bumps a generation when reset() or close() clears the listener registry and sends it with setup, reset and close. Android and iOS tag each onFeatureFlags notification with the generation they had when it fired, and Dart drops ones from an earlier generation, so the previous user's flags can't repopulate the replay cache. Co-Authored-By: Claude Opus 5.5 --- .../posthog/flutter/PosthogFlutterPlugin.kt | 18 +++++- .../PosthogFlutterPlugin.swift | 31 +++++++++- .../lib/src/feature_flags_listeners.dart | 11 +++- .../lib/src/posthog_flutter_io.dart | 17 +++++- .../test/posthog_flutter_io_test.dart | 59 +++++++++++++++---- .../test/snapshots/setup_request.json | 3 +- 6 files changed, 120 insertions(+), 19 deletions(-) diff --git a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt index 91f4eb8e..e24a0e01 100644 --- a/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt +++ b/posthog_flutter/android/src/main/kotlin/com/posthog/flutter/PosthogFlutterPlugin.kt @@ -93,6 +93,12 @@ class PosthogFlutterPlugin : @Volatile private var isHostActivityStopped = false + // Dart's feature flags lifecycle, sent with setup, reset and close. Each + // flags notification carries the one current when it fired, so Dart can + // drop loads from before a reset or close. + @Volatile + private var flagsGeneration = 0 + // A count, not a boolean: under multi-resume (Android 10+ split-screen) // two non-host activities can be resumed at once, and pausing one must // not read as "nothing covers the host" while the other still does. @@ -273,6 +279,7 @@ class PosthogFlutterPlugin : } "reset" -> { + updateFlagsGeneration(call) reset(result) } @@ -353,6 +360,7 @@ class PosthogFlutterPlugin : } "close" -> { + updateFlagsGeneration(call) close(result) } @@ -541,6 +549,7 @@ class PosthogFlutterPlugin : return } + updateFlagsGeneration(call) setupPostHog(args) result.success(null) @@ -817,7 +826,10 @@ class PosthogFlutterPlugin : onFeatureFlags = PostHogFeatureFlagsCallback { loaded -> Log.i("PostHogFlutter", "Android onFeatureFlags triggered. Notifying Dart.") - invokeFlutterMethod("onFeatureFlagsCallback", loaded.toFlutterMap()) + invokeFlutterMethod( + "onFeatureFlagsCallback", + loaded.toFlutterMap() + ("generation" to flagsGeneration), + ) } } @@ -2163,6 +2175,10 @@ class PosthogFlutterPlugin : } } + private fun updateFlagsGeneration(call: MethodCall) { + call.argument("flagsGeneration")?.let { flagsGeneration = it } + } + private fun close(result: Result) { try { PostHog.close() diff --git a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift index bb88b762..0f6dcac7 100644 --- a/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift +++ b/posthog_flutter/darwin/posthog_flutter/Sources/posthog_flutter/PosthogFlutterPlugin.swift @@ -45,6 +45,24 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { private var channel: FlutterMethodChannel? private var featureFlagsSubscription: PostHogFeatureFlagsSubscription? + // Dart's feature flags lifecycle, sent with setup, reset and close. Each + // flags notification carries the one current when it fired, so Dart can + // drop loads from before a reset or close. + private let flagsGenerationLock = NSLock() + private var flagsGenerationValue = 0 + private var flagsGeneration: Int { + get { + flagsGenerationLock.lock() + defer { flagsGenerationLock.unlock() } + return flagsGenerationValue + } + set { + flagsGenerationLock.lock() + flagsGenerationValue = newValue + flagsGenerationLock.unlock() + } + } + // Mint route for pushIdentityProvider, anchored to the engine whose setup() // installed the provider. A live owner is never displaced; provider-enabled // engines stay candidates so detach promotes the most recent survivor instead @@ -88,7 +106,15 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { } func featureFlagsDidUpdate(_ loaded: PostHogFeatureFlagsLoaded) { - invokeFlutterMethod("onFeatureFlagsCallback", arguments: Self.featureFlagsLoadedMap(loaded)) + var arguments = Self.featureFlagsLoadedMap(loaded) + arguments["generation"] = flagsGeneration + invokeFlutterMethod("onFeatureFlagsCallback", arguments: arguments) + } + + private func updateFlagsGeneration(_ call: FlutterMethodCall) { + if let generation = (call.arguments as? [String: Any])?["flagsGeneration"] as? Int { + flagsGeneration = generation + } } private static func featureFlagsLoadedMap(_ loaded: PostHogFeatureFlagsLoaded) -> [String: Any] { @@ -466,6 +492,7 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { public func handle(_ call: FlutterMethodCall, result: @escaping FlutterResult) { switch call.method { case "setup": + updateFlagsGeneration(call) setup(call, result: result) case "getFeatureFlag": getFeatureFlag(call, result: result) @@ -494,6 +521,7 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { case "distinctId": distinctId(result) case "reset": + updateFlagsGeneration(call) reset(result) case "enable": enable(result) @@ -526,6 +554,7 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin { case "addExceptionStep": addExceptionStep(call, result: result) case "close": + updateFlagsGeneration(call) close(result) case "sendMetaEvent": sendMetaEvent(call, result: result) diff --git a/posthog_flutter/lib/src/feature_flags_listeners.dart b/posthog_flutter/lib/src/feature_flags_listeners.dart index f94f299b..3ca8fa76 100644 --- a/posthog_flutter/lib/src/feature_flags_listeners.dart +++ b/posthog_flutter/lib/src/feature_flags_listeners.dart @@ -16,6 +16,12 @@ class FeatureFlagsListeners { final _listeners = {}; PostHogFeatureFlagsLoaded? _last; + int _generation = 0; + + /// Bumped by [clear]. The native SDKs tag each load with the generation + /// they last got, so a load sent before a `reset()` or `close()` reached + /// them can be told apart. + int get generation => _generation; PostHogFeatureFlagsSubscription add(OnFeatureFlagsCallback listener) { final token = Object(); @@ -52,5 +58,8 @@ class FeatureFlagsListeners { /// Forgets the last load, so new listeners don't get the previous user's /// flags after `reset()` or `close()`. - void clear() => _last = null; + void clear() { + _last = null; + _generation++; + } } diff --git a/posthog_flutter/lib/src/posthog_flutter_io.dart b/posthog_flutter/lib/src/posthog_flutter_io.dart index c8550152..73f5d0e8 100644 --- a/posthog_flutter/lib/src/posthog_flutter_io.dart +++ b/posthog_flutter/lib/src/posthog_flutter_io.dart @@ -75,6 +75,14 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { await cleanupSurveys(); return null; case 'onFeatureFlagsCallback': + final arguments = call.arguments; + // Sent before the native SDK got the latest reset() or close(), so + // these may be the previous user's flags. + if (arguments is! Map || + arguments['generation'] != + FeatureFlagsListeners.instance.generation) { + break; + } final loaded = featureFlagsLoadedFromMap(call.arguments); // First, so a throwing config callback can't starve listeners and a // reset() or close() it starts clears this load. @@ -196,6 +204,9 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { SurveyService().hideSurvey(); } + Map _flagsGenerationArguments() => + {'flagsGeneration': FeatureFlagsListeners.instance.generation}; + /// Flutter to Native Calls /// @override @@ -215,6 +226,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { await _methodChannel.invokeMethod('setup', { ...config.toMap(), ...flutterVersionSetupArguments(), + ..._flagsGenerationArguments(), }); } on PlatformException catch (exception) { printIfDebug('Exeption on setup: $exception'); @@ -512,7 +524,7 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - await _methodChannel.invokeMethod('reset'); + await _methodChannel.invokeMethod('reset', _flagsGenerationArguments()); } on PlatformException catch (exception) { printIfDebug('Exeption on reset: $exception'); } @@ -892,7 +904,8 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { } try { - return await _methodChannel.invokeMethod('close'); + return await _methodChannel.invokeMethod( + 'close', _flagsGenerationArguments()); } on PlatformException catch (exception) { printIfDebug('Exeption on close: $exception'); } diff --git a/posthog_flutter/test/posthog_flutter_io_test.dart b/posthog_flutter/test/posthog_flutter_io_test.dart index 91c2fdeb..f2f4599a 100644 --- a/posthog_flutter/test/posthog_flutter_io_test.dart +++ b/posthog_flutter/test/posthog_flutter_io_test.dart @@ -162,6 +162,14 @@ void main() { }); group('PosthogFlutterIO onFeatureFlags via setup', () { + // The native SDKs tag each load with the generation they last got. + Map nativeFlagsArgs(Map args, + {int? generation}) => + { + ...args, + 'generation': generation ?? FeatureFlagsListeners.instance.generation, + }; + test( 'setup initializes method call handler and registers callback if provided', () async { @@ -181,7 +189,7 @@ void main() { .handlePlatformMessage( channel.name, channel.codec.encodeMethodCall( - const MethodCall('onFeatureFlagsCallback', {}), + MethodCall('onFeatureFlagsCallback', nativeFlagsArgs({})), ), (ByteData? data) {}, ); @@ -224,12 +232,12 @@ void main() { await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .handlePlatformMessage( channel.name, - channel.codec.encodeMethodCall(const MethodCall( + channel.codec.encodeMethodCall(MethodCall( 'onFeatureFlagsCallback', - { + nativeFlagsArgs({ 'variants': {'on': true, 'variant': 'test', 'off': false}, 'errorsLoading': true, - }, + }), )), (ByteData? data) {}, ); @@ -249,7 +257,8 @@ void main() { .handlePlatformMessage( channel.name, channel.codec.encodeMethodCall( - const MethodCall('onFeatureFlagsCallback', {'variants': 123}), + MethodCall( + 'onFeatureFlagsCallback', nativeFlagsArgs({'variants': 123})), ), (ByteData? data) {}, ); @@ -267,12 +276,12 @@ void main() { await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .handlePlatformMessage( channel.name, - channel.codec.encodeMethodCall(const MethodCall( + channel.codec.encodeMethodCall(MethodCall( 'onFeatureFlagsCallback', - { + nativeFlagsArgs({ 'variants': {'on': true}, 'errorsLoading': false, - }, + }), )), (ByteData? data) {}, ); @@ -280,20 +289,44 @@ void main() { expect(received.single.variants, {'on': true}); }); - Future sendNativeFlags() => + Future sendNativeFlags({int? generation}) => TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger .handlePlatformMessage( channel.name, - channel.codec.encodeMethodCall(const MethodCall( + channel.codec.encodeMethodCall(MethodCall( 'onFeatureFlagsCallback', - { + nativeFlagsArgs({ 'variants': {'old-user-only': true}, 'errorsLoading': false, - }, + }, generation: generation), )), (ByteData? data) {}, ); + for (final (name, transition) in <(String, Future Function())>[ + ('reset', () => Posthog().reset()), + ('close', () => Posthog().close()), + ]) { + test('drops a native load sent before the $name reached native', + () async { + PosthogFlutterPlatformInterface.instance = posthogFlutterIO; + final received = []; + await posthogFlutterIO.setup( + PostHogConfig('test_project_token', onFeatureFlags: received.add)); + final before = FeatureFlagsListeners.instance.generation; + + await transition(); + await sendNativeFlags(generation: before); + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + await Future.delayed(Duration.zero); + + expect(received, isEmpty); + expect((log.last.arguments as Map)['flagsGeneration'], + FeatureFlagsListeners.instance.generation); + }); + } + for (final (name, transition) in <(String, Future Function())>[ ('reset', () => Posthog().reset()), ('close', () => Posthog().close()), @@ -342,7 +375,7 @@ void main() { .handlePlatformMessage( channel.name, channel.codec.encodeMethodCall( - const MethodCall('onFeatureFlagsCallback', {}), + MethodCall('onFeatureFlagsCallback', nativeFlagsArgs({})), ), (ByteData? data) => reply = data, ); diff --git a/posthog_flutter/test/snapshots/setup_request.json b/posthog_flutter/test/snapshots/setup_request.json index 367138b4..53afe1ce 100644 --- a/posthog_flutter/test/snapshots/setup_request.json +++ b/posthog_flutter/test/snapshots/setup_request.json @@ -77,7 +77,8 @@ "color": "blue" } } - } + }, + "flagsGeneration": 1 } } ] From b71f8d6a205f4ea8e6ddbb53b9932d173ff60461 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 16:06:54 -0400 Subject: [PATCH 6/7] fix: start receiving native flag loads when the first listener is added With native auto-init, Posthog().onFeatureFlags() can be the app's only Dart SDK call, and nothing created the platform that installs the channel handler, so native loads never reached the listener. Co-Authored-By: Claude Opus 5.5 --- posthog_flutter/lib/src/posthog.dart | 8 +++-- ...feature_flags_listener_auto_init_test.dart | 34 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 posthog_flutter/test/feature_flags_listener_auto_init_test.dart diff --git a/posthog_flutter/lib/src/posthog.dart b/posthog_flutter/lib/src/posthog.dart index d08213a6..d44fe063 100644 --- a/posthog_flutter/lib/src/posthog.dart +++ b/posthog_flutter/lib/src/posthog.dart @@ -546,8 +546,12 @@ class Posthog { /// subscription.unsubscribe(); /// ``` PostHogFeatureFlagsSubscription onFeatureFlags( - OnFeatureFlagsCallback callback) => - FeatureFlagsListeners.instance.add(callback); + OnFeatureFlagsCallback callback) { + // Creating the platform installs the channel handler native loads arrive + // on. With native auto-init this can be the app's only Dart SDK call. + _posthog; + return FeatureFlagsListeners.instance.add(callback); + } /// Sets person properties that are used only for feature flag evaluation. /// diff --git a/posthog_flutter/test/feature_flags_listener_auto_init_test.dart b/posthog_flutter/test/feature_flags_listener_auto_init_test.dart new file mode 100644 index 00000000..fc5061cb --- /dev/null +++ b/posthog_flutter/test/feature_flags_listener_auto_init_test.dart @@ -0,0 +1,34 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/posthog_flutter.dart'; + +// Its own file so no earlier test has created the platform instance, as in an +// app where native auto-init ran and onFeatureFlags is the first Dart call. +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('onFeatureFlags as the first SDK call receives native loads', () async { + const channel = MethodChannel('posthog_flutter'); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (_) async => null); + final received = []; + final subscription = Posthog().onFeatureFlags(received.add); + addTearDown(subscription.unsubscribe); + + ServicesBinding.instance.channelBuffers.push( + channel.name, + channel.codec + .encodeMethodCall(const MethodCall('onFeatureFlagsCallback', { + 'generation': 0, + 'variants': {'auto-init-flag': true}, + 'errorsLoading': false, + })), + (_) {}, + ); + await pumpEventQueue(); + + expect(received.map((loaded) => loaded.variants), [ + {'auto-init-flag': true} + ]); + }); +} From 4d81d9e8575b97efeb0b125ac8cb281902a7c4cb Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 28 Oct 2026 16:08:02 -0400 Subject: [PATCH 7/7] fix: deliver Android reloadFeatureFlags results to onFeatureFlags listeners posthog-android passes a manual reload only to its completion, so no onFeatureFlagsCallback arrives and listeners missed the load while new ones replayed the older snapshot. A reload that finishes after a reset or close is still dropped. Co-Authored-By: Claude Opus 5.5 --- .../lib/src/posthog_flutter_io.dart | 11 +++- .../test/posthog_flutter_io_test.dart | 62 +++++++++++++++++++ 2 files changed, 72 insertions(+), 1 deletion(-) diff --git a/posthog_flutter/lib/src/posthog_flutter_io.dart b/posthog_flutter/lib/src/posthog_flutter_io.dart index 73f5d0e8..9ec0a3f3 100644 --- a/posthog_flutter/lib/src/posthog_flutter_io.dart +++ b/posthog_flutter/lib/src/posthog_flutter_io.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'util/platform_io_stub.dart' if (dart.library.io) 'util/platform_io_real.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:posthog_flutter/src/surveys/survey_service.dart'; @@ -608,9 +609,17 @@ class PosthogFlutterIO extends PosthogFlutterPlatformInterface { return featureFlagsLoadedFromMap(null); } + final generation = FeatureFlagsListeners.instance.generation; try { - return featureFlagsLoadedFromMap( + final loaded = featureFlagsLoadedFromMap( await _methodChannel.invokeMethod('reloadFeatureFlags')); + // posthog-android hands a manual reload only to its completion, not to + // the config callback that sends onFeatureFlagsCallback. + if (defaultTargetPlatform == TargetPlatform.android && + generation == FeatureFlagsListeners.instance.generation) { + FeatureFlagsListeners.instance.deliver(loaded); + } + return loaded; } on PlatformException catch (exception) { printIfDebug('Exeption on reloadFeatureFlags: $exception'); return featureFlagsLoadedFromMap(null); diff --git a/posthog_flutter/test/posthog_flutter_io_test.dart b/posthog_flutter/test/posthog_flutter_io_test.dart index f2f4599a..43a352bc 100644 --- a/posthog_flutter/test/posthog_flutter_io_test.dart +++ b/posthog_flutter/test/posthog_flutter_io_test.dart @@ -1,5 +1,6 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:posthog_flutter/src/feature_flag_result.dart'; @@ -365,6 +366,67 @@ void main() { expect(received.single.variants, {'old-user-only': true}); }); + void mockReload(Future Function() reload) { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + log.add(call); + return call.method == 'reloadFeatureFlags' ? reload() : null; + }); + } + + const reloaded = { + 'variants': {'manual': true}, + 'errorsLoading': false, + }; + + test('Android reload result reaches active and later listeners', () async { + FeatureFlagsListeners.instance.clear(); + mockReload(() async => reloaded); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + + await posthogFlutterIO.reloadFeatureFlags(); + final late = []; + final lateSubscription = FeatureFlagsListeners.instance.add(late.add); + addTearDown(lateSubscription.unsubscribe); + await pumpEventQueue(); + + expect(received.single.variants, {'manual': true}); + expect(late.single.variants, {'manual': true}); + }); + + test('iOS reload result is left to the native notification', () async { + debugDefaultTargetPlatformOverride = TargetPlatform.iOS; + addTearDown(() => debugDefaultTargetPlatformOverride = null); + FeatureFlagsListeners.instance.clear(); + mockReload(() async => reloaded); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + + await posthogFlutterIO.reloadFeatureFlags(); + + expect(received, isEmpty); + }); + + test('drops an Android reload result that finishes after a reset', + () async { + PosthogFlutterPlatformInterface.instance = posthogFlutterIO; + final reply = Completer(); + mockReload(() => reply.future); + final received = []; + final subscription = FeatureFlagsListeners.instance.add(received.add); + addTearDown(subscription.unsubscribe); + + final reload = posthogFlutterIO.reloadFeatureFlags(); + await Posthog().reset(); + reply.complete(reloaded); + await reload; + + expect(received, isEmpty); + }); + test('does not invoke callback when no callback is registered', () async { // Setup without callback testConfig = PostHogConfig('test_project_token');