Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
705c311
chore!: pass the loaded flags to onFeatureFlags and reloadFeatureFlags
turnipdabeets Oct 23, 2026
0fe81a2
fix: complete the web reloadFeatureFlags with the reloaded flags
turnipdabeets Oct 23, 2026
bde0b01
chore: use PostHogFeatureFlagsCallback from posthog-android 4.0
turnipdabeets Oct 23, 2026
0c7aea5
chore: fix ktlint function-signature
turnipdabeets Oct 23, 2026
17d0d81
fix: await posthog-js reloadFeatureFlagsAsync on web
turnipdabeets Oct 23, 2026
d1e15d4
docs: pass the loaded flags in the setup example
turnipdabeets Oct 25, 2026
0a7ab2a
docs: drop best-effort web note from the person and group property me…
turnipdabeets Oct 25, 2026
c579d0b
feat: add an onFeatureFlags listener you can unsubscribe
turnipdabeets Oct 25, 2026
a194366
fix: isolate failing flag listeners, skip web registration without on…
turnipdabeets Oct 25, 2026
d4105d9
fix: deliver flag loads to onFeatureFlags listeners before the config…
turnipdabeets Oct 27, 2026
bada785
Merge branch 'chore/v6-adopt-posthog-android-4' into feat/v6-on-featu…
turnipdabeets Oct 28, 2026
1186846
fix(web): stop flag registrations left by close() or the posthog-js l…
turnipdabeets Oct 28, 2026
d81b089
fix: drop native flag loads sent before a reset or close reached native
turnipdabeets Oct 28, 2026
b69bf57
fix: start receiving native flag loads when the first listener is added
turnipdabeets Oct 28, 2026
26f2755
fix: deliver Android reloadFeatureFlags results to onFeatureFlags lis…
turnipdabeets Oct 28, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/v6-on-feature-flags-listener.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"posthog_flutter": minor
---

- Add `Posthog().onFeatureFlags()` to get notified after every feature flags load, returning a `PostHogFeatureFlagsSubscription` you can `unsubscribe()`
52 changes: 52 additions & 0 deletions api/posthog_flutter.api.json
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -273,6 +279,7 @@ class PosthogFlutterPlugin :
}

"reset" -> {
updateFlagsGeneration(call)
reset(result)
}

Expand Down Expand Up @@ -353,6 +360,7 @@ class PosthogFlutterPlugin :
}

"close" -> {
updateFlagsGeneration(call)
close(result)
}

Expand Down Expand Up @@ -541,6 +549,7 @@ class PosthogFlutterPlugin :
return
}

updateFlagsGeneration(call)
setupPostHog(args)

result.success(null)
Expand Down Expand Up @@ -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),
)
}
}

Expand Down Expand Up @@ -2163,6 +2175,10 @@ class PosthogFlutterPlugin :
}
}

private fun updateFlagsGeneration(call: MethodCall) {
call.argument<Int>("flagsGeneration")?.let { flagsGeneration = it }
}

private fun close(result: Result) {
try {
PostHog.close()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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] {
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -494,6 +521,7 @@ public class PosthogFlutterPlugin: NSObject, FlutterPlugin {
case "distinctId":
distinctId(result)
case "reset":
updateFlagsGeneration(call)
reset(result)
case "enable":
enable(result)
Expand Down Expand Up @@ -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)
Expand Down
39 changes: 34 additions & 5 deletions posthog_flutter/lib/posthog_flutter_web.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -11,6 +12,7 @@ import 'package:posthog_flutter/src/utils/channel_serialization.dart';
import 'package:posthog_flutter/src/utils/property_normalizer.dart';

import 'src/feature_flag_result.dart';
import 'src/feature_flags_listeners.dart';
import 'src/feature_flags_loaded.dart';
import 'src/logs/posthog_log_severity.dart';
import 'src/posthog_config.dart';
Expand All @@ -34,6 +36,11 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface {
/// Copied at setup. Event beforeSend runs here, before posthog-js, the same
/// way [PosthogFlutterIO] runs it before the platform channel.
List<BeforeSendCallback> _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
Expand Down Expand Up @@ -126,19 +133,38 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface {

WebCanvasMaskProvider(config).register();

if (config.onFeatureFlags != null && ph != null) {
final dartCallback = config.onFeatureFlags!;
// Skipped for a posthog-js stub without onFeatureFlags.
if (ph != null && (ph as JSObject).has('onFeatureFlags')) {
final dartCallback = config.onFeatureFlags;
// Repeated setup would otherwise deliver each load twice.
_featureFlagsUnsubscribe?.callAsFunction();
_featureFlagsUnsubscribe = null;
final generation = ++_featureFlagsGeneration;

// JS SDK calls with: (flags: string[], variants: Record, context?: {errorsLoading})
final jsCallback =
(JSArray jsFlags, JSObject jsFlagVariants, [JSObject? jsContext]) {
dartCallback(featureFlagsLoadedFromMap({
// The posthog-js loader snippet queues the registration and returns
// no unsubscribe, so a later setup() or close() can't remove it.
if (generation != _featureFlagsGeneration) return;
final loaded = featureFlagsLoadedFromMap({
'variants': jsFlagVariants.dartify(),
'errorsLoading': (jsContext?.dartify() as Map?)?['errorsLoading'],
}));
});
// First, so a throwing config callback can't starve listeners and a
// reset() or close() it starts clears this load.
FeatureFlagsListeners.instance.deliver(loaded);
dartCallback?.call(loaded);
}.toJS;

ph.onFeatureFlags(jsCallback);
// posthog-js replays loaded flags synchronously, and a callback may call
// close() or setup() before this returns the unsubscribe.
final unsubscribe = ph.onFeatureFlags(jsCallback);
if (generation == _featureFlagsGeneration) {
_featureFlagsUnsubscribe = unsubscribe;
} else {
unsubscribe?.callAsFunction();
}
}
}

Expand Down Expand Up @@ -500,6 +526,9 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface {
@override
Future<void> close() async {
_currentScreen = null;
_featureFlagsUnsubscribe?.callAsFunction();
_featureFlagsUnsubscribe = null;
_featureFlagsGeneration++;
return handleWebMethodCall(const MethodCall('close'));
}

Expand Down
65 changes: 65 additions & 0 deletions posthog_flutter/lib/src/feature_flags_listeners.dart
Original file line number Diff line number Diff line change
@@ -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 = <Object, OnFeatureFlagsCallback>{};
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++;
}
}
17 changes: 17 additions & 0 deletions posthog_flutter/lib/src/feature_flags_loaded.dart
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import 'package:meta/meta.dart';

/// The feature flags passed to [PostHogConfig.onFeatureFlags] and returned by
/// [Posthog.reloadFeatureFlags].
///
Expand Down Expand Up @@ -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();
}
Loading
Loading