From 526286098fb7aa3a216cc493c7b2d70bd4a41552 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Wed, 30 Sep 2026 11:05:59 -0400 Subject: [PATCH 1/2] fix(surveys): show a survey once PosthogObserver has a context --- .changeset/survey-wait-for-context.md | 5 + posthog_flutter/lib/src/posthog_observer.dart | 2 + .../lib/src/surveys/survey_service.dart | 56 ++++++-- .../test/survey_wait_for_context_test.dart | 135 ++++++++++++++++++ 4 files changed, 186 insertions(+), 12 deletions(-) create mode 100644 .changeset/survey-wait-for-context.md create mode 100644 posthog_flutter/test/survey_wait_for_context_test.dart diff --git a/.changeset/survey-wait-for-context.md b/.changeset/survey-wait-for-context.md new file mode 100644 index 00000000..0c270e9b --- /dev/null +++ b/.changeset/survey-wait-for-context.md @@ -0,0 +1,5 @@ +--- +"posthog_flutter": patch +--- + +Show a survey that arrives before `PosthogObserver` has a context once the next navigation happens, instead of dropping it and blocking later surveys. diff --git a/posthog_flutter/lib/src/posthog_observer.dart b/posthog_flutter/lib/src/posthog_observer.dart index 071863a0..fbafdcf8 100644 --- a/posthog_flutter/lib/src/posthog_observer.dart +++ b/posthog_flutter/lib/src/posthog_observer.dart @@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart'; import 'package:meta/meta.dart'; import 'posthog.dart'; +import 'surveys/survey_service.dart'; /// Extracts a PostHog screen name from Flutter [RouteSettings]. /// @@ -113,6 +114,7 @@ class PosthogObserver extends RouteObserver> // don't clear current context if it's null if (context != null) { _currentContext = context; + SurveyService().onContextAvailable(); } } diff --git a/posthog_flutter/lib/src/surveys/survey_service.dart b/posthog_flutter/lib/src/surveys/survey_service.dart index 56476cd9..80f40b18 100644 --- a/posthog_flutter/lib/src/surveys/survey_service.dart +++ b/posthog_flutter/lib/src/surveys/survey_service.dart @@ -26,6 +26,7 @@ class SurveyService { Route? _currentSurveyRoute; PostHogDisplaySurvey? _currentSurvey; Completer? _programmaticDismissal; + Completer? _contextWait; /// Shows a survey using the PosthogObserver context Future showSurvey( @@ -39,24 +40,48 @@ class SurveyService { return; } - // Use the PosthogObserver's context to show the survey - if (PosthogObserver.currentContext != null) { - printIfDebug('[PostHog] Using PosthogObserver context for survey'); - return _showSurveyWithNavigator( - survey, - onShown, - onResponse, - onClosed, - PosthogObserver.currentContext!, + if (PosthogObserver.currentContext == null) { + printIfDebug( + '[PostHog] No valid context to show the survey yet, it will be shown on the next navigation. If it never shows, make sure that you have installed PosthogObserver correctly in your app.', ); + // The native SDK keeps this survey active until it is closed, and closing + // it would record a dismissal for a survey the user never saw. + if (!await _waitForContext() || _isShowingSurvey) return; } - // If we can't show the survey, log an error - printIfDebug( - '[PostHog] Cannot show survey: No valid context found. To fix this make sure that you have installed PosthogObserver correctly in your app.', + final context = PosthogObserver.currentContext; + if (context == null || !context.mounted) return; + + printIfDebug('[PostHog] Using PosthogObserver context for survey'); + return _showSurveyWithNavigator( + survey, + onShown, + onResponse, + onClosed, + context, ); } + /// Completes with true once [PosthogObserver] reports a context, or false + /// when the wait was cancelled by [hideSurvey] or replaced by a newer survey. + Future _waitForContext() async { + final contextWait = Completer(); + final replaced = _contextWait; + _contextWait = contextWait; + if (replaced != null && !replaced.isCompleted) replaced.complete(); + await contextWait.future; + if (_contextWait != contextWait) return false; + _contextWait = null; + return true; + } + + /// Called by [PosthogObserver] when it has a context, so a survey that + /// arrived before then can be shown. + void onContextAvailable() { + final contextWait = _contextWait; + if (contextWait != null && !contextWait.isCompleted) contextWait.complete(); + } + /// Shows a survey using a navigator context Future _showSurveyWithNavigator( PostHogDisplaySurvey survey, @@ -150,6 +175,13 @@ class SurveyService { /// Hides any active survey void hideSurvey({PostHogDisplaySurvey? survey}) { + if (survey == null) { + final contextWait = _contextWait; + _contextWait = null; + if (contextWait != null && !contextWait.isCompleted) { + contextWait.complete(); + } + } if (survey != null && !identical(survey, _currentSurvey)) return; if (!_isShowingSurvey || _isDismissingSurvey) { return; diff --git a/posthog_flutter/test/survey_wait_for_context_test.dart b/posthog_flutter/test/survey_wait_for_context_test.dart new file mode 100644 index 00000000..68837bdb --- /dev/null +++ b/posthog_flutter/test/survey_wait_for_context_test.dart @@ -0,0 +1,135 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:posthog_flutter/src/posthog_observer.dart'; +import 'package:posthog_flutter/src/surveys/models/posthog_display_survey.dart'; +import 'package:posthog_flutter/src/surveys/survey_service.dart'; +import 'package:posthog_flutter/src/surveys/widgets/survey_bottom_sheet.dart'; + +void main() { + PostHogDisplaySurvey survey(String id) { + return PostHogDisplaySurvey.fromDict({ + 'id': id, + 'name': 'Test survey', + 'questions': [ + {'type': 'link', 'question': id, 'isOptional': false, 'link': ''}, + ], + }); + } + + Future pumpAppWithObserver(WidgetTester tester) async { + await tester.pumpWidget( + MaterialApp( + navigatorObservers: [PosthogObserver()], + home: const Scaffold(body: Text('Home')), + ), + ); + await tester.pumpAndSettle(); + } + + tearDown(() { + SurveyService().hideSurvey(); + PosthogObserver.clearCurrentContext(); + }); + + testWidgets('shows a survey that arrived before the observer had a context', ( + tester, + ) async { + final shown = []; + final closed = []; + + final showSurvey = SurveyService().showSurvey( + survey('early'), + (survey) => shown.add(survey.id), + (_, __, ___) async => null, + (survey) => closed.add(survey.id), + ); + await tester.pump(); + expect(closed, isEmpty); + + await pumpAppWithObserver(tester); + + expect(find.byType(SurveyBottomSheet), findsOneWidget); + expect(shown, ['early']); + expect(closed, isEmpty); + + SurveyService().hideSurvey(); + await tester.pumpAndSettle(); + await showSurvey; + }); + + testWidgets( + 'shows a waiting survey on the next navigation after the context was cleared', + ( + tester, + ) async { + await pumpAppWithObserver(tester); + PosthogObserver.clearCurrentContext(); + + final showSurvey = SurveyService().showSurvey( + survey('after-clear'), + (_) {}, + (_, __, ___) async => null, + (_) {}, + ); + await tester.pumpAndSettle(); + expect(find.byType(SurveyBottomSheet), findsNothing); + + tester.state(find.byType(Navigator)).push( + MaterialPageRoute( + builder: (_) => const Scaffold(body: Text('Next')), + ), + ); + await tester.pumpAndSettle(); + + expect(find.byType(SurveyBottomSheet), findsOneWidget); + + SurveyService().hideSurvey(); + await tester.pumpAndSettle(); + await showSurvey; + }); + + testWidgets('does not show a waiting survey that was hidden', (tester) async { + final closed = []; + + final showSurvey = SurveyService().showSurvey( + survey('hidden'), + (_) {}, + (_, __, ___) async => null, + (survey) => closed.add(survey.id), + ); + SurveyService().hideSurvey(); + await showSurvey; + + await pumpAppWithObserver(tester); + + expect(find.byType(SurveyBottomSheet), findsNothing); + expect(closed, isEmpty); + }); + + testWidgets('a newer survey replaces one waiting for a context', ( + tester, + ) async { + final first = SurveyService().showSurvey( + survey('first'), + (_) {}, + (_, __, ___) async => null, + (_) {}, + ); + final second = SurveyService().showSurvey( + survey('second'), + (_) {}, + (_, __, ___) async => null, + (_) {}, + ); + await first; + + await pumpAppWithObserver(tester); + + expect(find.text('second'), findsOneWidget); + expect(find.text('first'), findsNothing); + + SurveyService().hideSurvey(); + await tester.pumpAndSettle(); + await second; + }); +} From b68e1e35300eeac509fd79d40f297c50b6301219 Mon Sep 17 00:00:00 2001 From: Anna Garcia Date: Thu, 1 Oct 2026 10:22:17 -0400 Subject: [PATCH 2/2] refactor(surveys): carry the context-wait result in its completer Co-Authored-By: Claude Opus 5.5 (1M context) --- .../lib/src/surveys/survey_service.dart | 43 ++++++++----------- 1 file changed, 19 insertions(+), 24 deletions(-) diff --git a/posthog_flutter/lib/src/surveys/survey_service.dart b/posthog_flutter/lib/src/surveys/survey_service.dart index 80f40b18..2ea8b32a 100644 --- a/posthog_flutter/lib/src/surveys/survey_service.dart +++ b/posthog_flutter/lib/src/surveys/survey_service.dart @@ -26,7 +26,7 @@ class SurveyService { Route? _currentSurveyRoute; PostHogDisplaySurvey? _currentSurvey; Completer? _programmaticDismissal; - Completer? _contextWait; + Completer? _contextWait; /// Shows a survey using the PosthogObserver context Future showSurvey( @@ -40,17 +40,19 @@ class SurveyService { return; } - if (PosthogObserver.currentContext == null) { + var context = PosthogObserver.currentContext; + if (context == null) { printIfDebug( '[PostHog] No valid context to show the survey yet, it will be shown on the next navigation. If it never shows, make sure that you have installed PosthogObserver correctly in your app.', ); // The native SDK keeps this survey active until it is closed, and closing // it would record a dismissal for a survey the user never saw. - if (!await _waitForContext() || _isShowingSurvey) return; + do { + if (!await _waitForContext() || _isShowingSurvey) return; + context = PosthogObserver.currentContext; + } while (context == null); } - - final context = PosthogObserver.currentContext; - if (context == null || !context.mounted) return; + if (!context.mounted) return; printIfDebug('[PostHog] Using PosthogObserver context for survey'); return _showSurveyWithNavigator( @@ -64,23 +66,22 @@ class SurveyService { /// Completes with true once [PosthogObserver] reports a context, or false /// when the wait was cancelled by [hideSurvey] or replaced by a newer survey. - Future _waitForContext() async { - final contextWait = Completer(); - final replaced = _contextWait; + Future _waitForContext() { + _endContextWait(show: false); + final contextWait = Completer(); _contextWait = contextWait; - if (replaced != null && !replaced.isCompleted) replaced.complete(); - await contextWait.future; - if (_contextWait != contextWait) return false; + return contextWait.future; + } + + void _endContextWait({required bool show}) { + final contextWait = _contextWait; _contextWait = null; - return true; + contextWait?.complete(show); } /// Called by [PosthogObserver] when it has a context, so a survey that /// arrived before then can be shown. - void onContextAvailable() { - final contextWait = _contextWait; - if (contextWait != null && !contextWait.isCompleted) contextWait.complete(); - } + void onContextAvailable() => _endContextWait(show: true); /// Shows a survey using a navigator context Future _showSurveyWithNavigator( @@ -175,13 +176,7 @@ class SurveyService { /// Hides any active survey void hideSurvey({PostHogDisplaySurvey? survey}) { - if (survey == null) { - final contextWait = _contextWait; - _contextWait = null; - if (contextWait != null && !contextWait.isCompleted) { - contextWait.complete(); - } - } + if (survey == null) _endContextWait(show: false); if (survey != null && !identical(survey, _currentSurvey)) return; if (!_isShowingSurvey || _isDismissingSurvey) { return;