Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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 @@ -6110,6 +6132,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"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -274,6 +280,7 @@ class PosthogFlutterPlugin :
}

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

Expand Down Expand Up @@ -354,6 +361,7 @@ class PosthogFlutterPlugin :
}

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

Expand Down Expand Up @@ -550,6 +558,7 @@ class PosthogFlutterPlugin :
return
}

updateFlagsGeneration(call)
setupPostHog(args)

result.success(null)
Expand Down Expand Up @@ -826,7 +835,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 @@ -2173,6 +2185,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
48 changes: 42 additions & 6 deletions posthog_flutter/lib/posthog_flutter_web.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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';
Expand All @@ -36,6 +38,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 @@ -135,19 +142,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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[P1] Clear web replay when the queued reset reaches JavaScript

IMPORTANT · REQUIRED
Location: posthog_flutter/lib/posthog_flutter_web.dart:165, RIGHT.
Change relationship: Introduced replay-cache defect exposed by the inherited async beforeSend sequencing.

When a flags response arrives while reset waits behind an asynchronous beforeSend, a listener added after await Posthog().reset() completes receives the previous user's flags. The public getter already returns null, so the new replay cache disagrees with the reset SDK state.

A supported caller can reach this when recording a logout event:

final captured = Posthog().capture(eventName: 'Logout clicked');
final resetting = Posthog().reset();
await Future.wait([captured, resetting]);
final subscription = Posthog().onFeatureFlags(updateUi);

The inherited 5.58.2 changelog explicitly supports reset after an unawaited capture by preserving capture→reset order. The required conditions are an async beforeSend still holding that capture and an old-user flags request completing during the wait:

  1. Public reset clears Dart replay at lib/src/posthog.dart:393.
  2. The inherited CallOrder correctly queues JavaScript reset behind the capture (posthog_flutter_web.dart:73–74,406–407).
  3. A valid old-user response arrives before JavaScript receives reset. The callback here accepts and caches it; its generation only tracks setup/close.
  4. Capture is released, then JavaScript resets, clearing its flags and starting an asynchronous anonymous-user reload.
  5. A new Dart listener replays the retained old snapshot. Nothing clears it again at reset handover.

This is a completed-reset replay failure, not a claim that active callbacks during the pending reset are themselves forbidden. It can show stale flag-driven UI after logout until a fresh response replaces the snapshot. No getter corruption, authorization bypass or backend disclosure was demonstrated.

The new API promises replay of current values (posthog.dart:517–529), and its registry explicitly clears replay to avoid previous-user flags (feature_flags_listeners.dart:59–64). The reset/cache contracts at PostHog/sdk-specs@b1bb2dee5941f418adb270ea06d79f7d28bb1aba likewise require user-scoped cache invalidation: openspec/specs/reset/spec.md, openspec/specs/feature-flag-cache/spec.md and acceptance/private/feature-flag-cache.feature.

Executed regression

The three-case review_web_before_send_flags_test.dart exercises public Posthog calls with a source-faithful browser stub, without mutating private registry state. It holds capture in beforeSend, emits the old response only while JS reset remains queued, releases capture, awaits reset, checks the public getter, then attaches a late listener.

Executed under the same Flutter3.35.4/Dart3.9.2/Chromium1248 offline environment on each snapshot:

flutter test --no-pub --platform chrome --dart-define=FLUTTER_BUILD_NAME=5.58.2 --dart-define=FLUTTER_BUILD_NUMBER=1 test/review_web_before_send_flags_test.dart -r expanded
Snapshot Result
Current head 53e1279f9449e040b3a1b94cad6fa043eb559ff4 Exit1: one failure, two passing controls. Getter is null; late replay is {old-user: true}.
Current base d04ec3af0934e2f1afbf0840ab3f73f173584040 Exit0: reset/order/getter controls pass; runtime API absent, not an equivalent subscription pass.
Prior-reviewed 4d81d9e8575b97efeb0b125ac8cb281902a7c4cb Exit0: all three runtime cases pass. Reset is not queued, and late replay remains empty.

Current failure:

JS reset completed; queued=true; public old-user getter=null
late values after awaited reset: [{old-user: true}]
Expected: empty
Actual: PostHogFeatureFlagsLoaded(variants: {old-user: true}, errorsLoading: false)

Ordinary awaited reset without a held capture passes. A later fresh response correctly replaces the stale replay snapshot, bounding the observed impact.

The stub does not invent an empty-notification omission: actual browser posthog-core.ts:3847–3993 clears persistence/flags then starts reload, and packages/browser-common/src/feature-flags.ts:1994–2013 does not fire an empty flags notification (PostHog/posthog-js@a055ee65e61a876a7b399271b9df026d22dbb144).

Consider making replay invalidation part of the ordered JavaScript reset handover, preserving capture→reset ordering, the browser registration and fresh post-reset notifications. Clearing indiscriminately after awaiting reset could discard a valid replacement notification. Add this regression with its ordinary-reset/fresh-response controls. No proposed correction was applied or tested.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 1538fa6 — web reset() now clears replay when the queued reset reaches posthog-js, with your scenario as a regression test.

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 @@ -378,7 +404,14 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface {

@override
Future<void> 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
Expand Down Expand Up @@ -532,6 +565,9 @@ class PosthogFlutterWeb extends PosthogFlutterPlatformInterface {
@override
Future<void> close() async {
_currentScreen = null;
_featureFlagsUnsubscribe?.callAsFunction();
_featureFlagsUnsubscribe = null;
_featureFlagsGeneration++;
return _call(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