From fc314990143d9d00146fd27560190c5d3cc4efac Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Wed, 5 Aug 2026 18:12:25 +0530 Subject: [PATCH 1/2] feat: support survey-interaction segment filters (ENG-1275) Ports the client half of the web SDK change for interaction-based segment filters ("have seen X", "have completed X", ...). Membership for those filters is computed server-side and can flip the moment a contact interacts with a survey, so the SDK now refetches user state instead of waiting for it to expire. - Add `InteractionSource` and `TInteractionRefresh`, plus the per-survey `interactionRefresh` gate on `TSurvey`. The parser is tolerant: a missing or non-boolean flag reads as false, so a partial object from the server can never fail the workspace-state decode and blank out every survey. - Add `refreshSegmentsAfterInteraction`: no-op for anonymous users, no-op unless the server flagged that survey and event, otherwise nudge the UpdateQueue so a display -> response -> finish burst debounces into one request. - Emit a new `FinishedEvent` for the `onFinished` bridge flag, and post it from the WebView harness. The flag was already tolerated by the parser but produced no event, so "have completed X" had no client-side trigger. - Wire all three lifecycle events in the WebView host, with a per-showing guard so a repeated event cannot cost a second request. `TSurvey.toJson` returns the original decoded map, so the gate reaches the survey runtime unchanged with no extra plumbing. --- packages/formbricks/lib/src/types/survey.dart | 70 ++++++ .../lib/src/user/interaction_refresh.dart | 40 +++ .../lib/src/widgets/survey_html.dart | 8 + .../lib/src/widgets/survey_webview.dart | 24 ++ .../lib/src/widgets/webview_event.dart | 13 +- .../test/user/interaction_refresh_test.dart | 233 ++++++++++++++++++ .../test/widgets/survey_html_test.dart | 35 +++ .../test/widgets/survey_webview_test.dart | 81 +++++- .../test/widgets/webview_event_test.dart | 23 +- 9 files changed, 522 insertions(+), 5 deletions(-) create mode 100644 packages/formbricks/lib/src/user/interaction_refresh.dart create mode 100644 packages/formbricks/test/user/interaction_refresh_test.dart diff --git a/packages/formbricks/lib/src/types/survey.dart b/packages/formbricks/lib/src/types/survey.dart index f267f1d..432e4f2 100644 --- a/packages/formbricks/lib/src/types/survey.dart +++ b/packages/formbricks/lib/src/types/survey.dart @@ -23,6 +23,7 @@ class TSurvey { this.recontactDays, this.displayPercentage, this.segment, + this.interactionRefresh, required Map raw, }) : _raw = raw; @@ -62,6 +63,11 @@ class TSurvey { : TSurveySegment.fromJson( (json['segment'] as Map).cast(), ), + interactionRefresh: json['interactionRefresh'] is Map + ? TInteractionRefresh.fromJson( + (json['interactionRefresh'] as Map).cast(), + ) + : null, raw: json, ); @@ -101,6 +107,10 @@ class TSurvey { /// The segment targeting this survey, or null when untargeted. final TSurveySegment? segment; + /// Whether interacting with this survey can change some live survey's segment + /// membership. Null unless the workspace uses survey-interaction targeting. + final TInteractionRefresh? interactionRefresh; + final Map _raw; /// Whether the survey is available in more than one language. @@ -110,6 +120,66 @@ class TSurvey { Map toJson() => _raw; } +/// The survey-lifecycle moments that can flip interaction-based segment +/// membership. Names match the source names used by the JS SDK. +enum InteractionSource { + /// A display was created — drives `have seen` / `have not seen`. + onDisplay, + + /// A response was created — drives `have started responding to`. + onResponse, + + /// The response was finished — drives `have completed` / `have not completed`. + onFinished, +} + +/// Per-survey gate for the post-interaction segment refresh. +/// +/// Each flag says whether interacting with *this* survey via that event can +/// change some live survey's segment membership — so a survey referenced only by +/// a "have seen" filter refreshes on display but not on response or finish, and +/// a survey no interaction filter points at never refreshes at all. +/// +/// The client API attaches this only for workspaces that use survey-interaction +/// targeting, so it is absent for everyone else, and present-but-all-false for +/// surveys in such a workspace that no interaction filter references. +class TInteractionRefresh { + /// Creates a gate. Every flag defaults to "do not refresh". + const TInteractionRefresh({ + this.onDisplay = false, + this.onResponse = false, + this.onFinished = false, + }); + + /// Builds a gate from decoded JSON. + /// + /// Deliberately tolerant: a missing or non-boolean flag reads as `false`, so a + /// partial object from the server can never fail the workspace-state decode + /// and blank out every survey. + factory TInteractionRefresh.fromJson(Map json) => + TInteractionRefresh( + onDisplay: json['onDisplay'] == true, + onResponse: json['onResponse'] == true, + onFinished: json['onFinished'] == true, + ); + + /// Whether a display can flip membership. + final bool onDisplay; + + /// Whether a created response can flip membership. + final bool onResponse; + + /// Whether finishing the survey can flip membership. + final bool onFinished; + + /// Whether an interaction of this kind should trigger a user-state refresh. + bool shouldRefresh(InteractionSource source) => switch (source) { + InteractionSource.onDisplay => onDisplay, + InteractionSource.onResponse => onResponse, + InteractionSource.onFinished => onFinished, + }; +} + /// The minimal segment shape read for targeting: `{ id, hasFilters }`. /// Tolerates the legacy cached shape carrying a full `filters` array. class TSurveySegment { diff --git a/packages/formbricks/lib/src/user/interaction_refresh.dart b/packages/formbricks/lib/src/user/interaction_refresh.dart new file mode 100644 index 0000000..1c2ec08 --- /dev/null +++ b/packages/formbricks/lib/src/user/interaction_refresh.dart @@ -0,0 +1,40 @@ +/// Post-interaction segment refresh. +/// +/// A `surveyInteraction` segment filter ("have seen X", "have completed X", ...) +/// can change who a contact is the moment they interact with a survey. Segment +/// membership is only ever computed by the backend, so the local bookkeeping in +/// the WebView host (displays / responses) is not enough — the user state has to +/// be refetched. +library; + +import 'dart:async'; + +import '../common/logger.dart'; +import '../types/survey.dart'; +import 'update_queue.dart'; + +/// Pulls fresh server-computed `segments` after an interaction that can flip +/// segment membership, instead of waiting for the user state to expire. +/// +/// The refresh is deliberately gated twice, because a `/user` sync is not cheap: +/// * no-op for anonymous users, who never receive segments in the first place; +/// * no-op unless the backend set the bit for this survey and this event. +/// +/// It is routed through the [UpdateQueue] rather than sending directly, so a +/// display -> response -> finish burst coalesces into a single request. +void refreshSegmentsAfterInteraction( + String? userId, + TSurvey survey, + InteractionSource source, +) { + if (userId == null || userId.isEmpty) return; + if (!(survey.interactionRefresh?.shouldRefresh(source) ?? false)) return; + + Logger.debug( + 'Refreshing segments after ${source.name} on survey ${survey.id}', + ); + + final queue = UpdateQueue.instance; + queue.updateUserId(userId); + unawaited(queue.processUpdates()); +} diff --git a/packages/formbricks/lib/src/widgets/survey_html.dart b/packages/formbricks/lib/src/widgets/survey_html.dart index ffdd668..64e491b 100644 --- a/packages/formbricks/lib/src/widgets/survey_html.dart +++ b/packages/formbricks/lib/src/widgets/survey_html.dart @@ -134,6 +134,13 @@ String buildSurveyHtml(SurveyHtmlOptions options) { postFormbricksMessage({ onResponseCreated: true }); }; + // Fires once the finished response has been accepted by the backend — the + // runtime gates this on isResponseSendingFinished, and passing + // getSetIsResponseSendingFinished below flips that initial state to false. + function onFinished() { + postFormbricksMessage({ onFinished: true }); + }; + function getSetIsResponseSendingFinished() { /* noop */ }; function getSetIsError() { /* noop */ }; @@ -204,6 +211,7 @@ String buildSurveyHtml(SurveyHtmlOptions options) { ...options, onDisplayCreated, onResponseCreated, + onFinished, onClose, getSetIsResponseSendingFinished, getSetIsError, diff --git a/packages/formbricks/lib/src/widgets/survey_webview.dart b/packages/formbricks/lib/src/widgets/survey_webview.dart index 9a389f1..19eec32 100644 --- a/packages/formbricks/lib/src/widgets/survey_webview.dart +++ b/packages/formbricks/lib/src/widgets/survey_webview.dart @@ -19,6 +19,7 @@ import '../common/utils.dart'; import '../survey/survey_store.dart'; import '../types/config.dart'; import '../types/survey.dart'; +import '../user/interaction_refresh.dart'; import 'default_webview_host.dart'; import 'survey_html.dart'; import 'webview_event.dart'; @@ -79,6 +80,9 @@ class _SurveyWebViewState extends State { // then close) can't clobber each other. Future _configOps = Future.value(); + // Interaction sources already refreshed during this showing. + final Set _refreshedSources = {}; + FormbricksConfig get _config => widget.config ?? FormbricksConfig.instance; SurveyStore get _store => widget.store ?? SurveyStore.instance; @@ -253,8 +257,12 @@ class _SurveyWebViewState extends State { switch (event) { case DisplayCreatedEvent(): _enqueueConfigOp(_recordDisplay); + _refreshSegmentsOnce(InteractionSource.onDisplay); case ResponseCreatedEvent(): _enqueueConfigOp(_recordResponse); + _refreshSegmentsOnce(InteractionSource.onResponse); + case FinishedEvent(): + _refreshSegmentsOnce(InteractionSource.onFinished); case OpenExternalUrlEvent(:final url): unawaited(openExternalUrl(url, launch: widget.launch)); case CloseEvent(): @@ -266,6 +274,22 @@ class _SurveyWebViewState extends State { } } + /// Forwards an interaction to the segment refresh at most once per source. + /// + /// One `State` lives per survey showing, so this is scoped to that showing. + /// The survey runtime guards `onResponseCreated` itself, but `onFinished` is + /// not guarded there, and a self-hosted server may serve an older bundle — so + /// the refresh is gated here too. Only the refresh is gated; the local + /// displays/responses bookkeeping keeps its existing behaviour. + void _refreshSegmentsOnce(InteractionSource source) { + if (!_refreshedSources.add(source)) return; + refreshSegmentsAfterInteraction( + _config.getOrNull()?.user.data.userId, + widget.survey, + source, + ); + } + void _handleWebViewLoadError() { if (!mounted) return; Logger.error('Survey WebView failed to load. Closing survey.'); diff --git a/packages/formbricks/lib/src/widgets/webview_event.dart b/packages/formbricks/lib/src/widgets/webview_event.dart index a52b6cd..aaf7750 100644 --- a/packages/formbricks/lib/src/widgets/webview_event.dart +++ b/packages/formbricks/lib/src/widgets/webview_event.dart @@ -30,6 +30,13 @@ final class ResponseCreatedEvent extends WebViewEvent { const ResponseCreatedEvent(); } +/// The survey was completed and the finished response was accepted by the +/// backend (the runtime gates this on `isResponseSendingFinished`). +final class FinishedEvent extends WebViewEvent { + /// Creates a finished event. + const FinishedEvent(); +} + /// The runtime asked to close the survey. final class CloseEvent extends WebViewEvent { /// Creates a close event. @@ -74,9 +81,8 @@ final class GeometryEvent extends WebViewEvent { /// - A `Console` payload maps to a single [ConsoleEvent] (mutually exclusive, /// like the runtime handler). /// - Otherwise one event is emitted per truthy flag, in handler order -/// (display, response, open-external-url, close). A well-formed but -/// non-actionable payload (e.g. `{onFinished:true}`) yields `const []` -/// quietly. +/// (display, response, finished, open-external-url, close). A well-formed but +/// non-actionable payload (e.g. `{}`) yields `const []` quietly. List parseWebViewEvents(String raw) { final map = _decodeMessage(raw); if (map == null) return const []; @@ -98,6 +104,7 @@ List parseWebViewEvents(String raw) { if (map['onResponseCreated'] == true) { events.add(const ResponseCreatedEvent()); } + if (map['onFinished'] == true) events.add(const FinishedEvent()); if (map['onOpenExternalURL'] == true) { events.add(OpenExternalUrlEvent(_externalUrl(map)!)); } diff --git a/packages/formbricks/test/user/interaction_refresh_test.dart b/packages/formbricks/test/user/interaction_refresh_test.dart new file mode 100644 index 0000000..f58a522 --- /dev/null +++ b/packages/formbricks/test/user/interaction_refresh_test.dart @@ -0,0 +1,233 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:formbricks/src/types/survey.dart'; +import 'package:formbricks/src/user/interaction_refresh.dart'; +import 'package:formbricks/src/user/update_queue.dart'; + +TSurvey _survey({ + TInteractionRefresh? interactionRefresh, + String id = 'survey-a', +}) => + TSurvey( + id: id, + triggers: const [], + languages: const [], + delay: 0, + interactionRefresh: interactionRefresh, + raw: {'id': id}, + ); + +void main() { + group('refreshSegmentsAfterInteraction', () { + // The real queue is used so the production call path is exercised. Its + // `pendingUserId` reports whether `updateUserId` was reached; the debounced + // flush is cancelled in tearDown so no timer outlives the test. + setUp(UpdateQueue.resetInstance); + tearDown(UpdateQueue.resetInstance); + + test('no-ops for an anonymous user even when the gate is open', () { + refreshSegmentsAfterInteraction( + null, + _survey( + interactionRefresh: const TInteractionRefresh( + onDisplay: true, + onResponse: true, + onFinished: true, + ), + ), + InteractionSource.onDisplay, + ); + + expect(UpdateQueue.instance.isEmpty, isTrue); + }); + + test('no-ops for an empty user id', () { + refreshSegmentsAfterInteraction( + '', + _survey( + interactionRefresh: const TInteractionRefresh(onDisplay: true), + ), + InteractionSource.onDisplay, + ); + + expect(UpdateQueue.instance.isEmpty, isTrue); + }); + + test('no-ops when the gate is absent — no interaction targeting', () { + refreshSegmentsAfterInteraction( + 'user-1', + _survey(), + InteractionSource.onDisplay, + ); + + expect(UpdateQueue.instance.isEmpty, isTrue); + }); + + test('no-ops when every flag is false', () { + refreshSegmentsAfterInteraction( + 'user-1', + _survey(interactionRefresh: const TInteractionRefresh()), + InteractionSource.onDisplay, + ); + + expect(UpdateQueue.instance.isEmpty, isTrue); + }); + + test('no-ops when only a different source is flagged', () { + refreshSegmentsAfterInteraction( + 'user-1', + _survey( + interactionRefresh: const TInteractionRefresh(onDisplay: true), + ), + InteractionSource.onResponse, + ); + + expect(UpdateQueue.instance.isEmpty, isTrue); + }); + + test('refreshes when the matching flag is set', () { + for (final (source, gate) in [ + ( + InteractionSource.onDisplay, + const TInteractionRefresh(onDisplay: true) + ), + ( + InteractionSource.onResponse, + const TInteractionRefresh(onResponse: true) + ), + ( + InteractionSource.onFinished, + const TInteractionRefresh(onFinished: true) + ), + ]) { + UpdateQueue.resetInstance(); + refreshSegmentsAfterInteraction( + 'user-1', + _survey(interactionRefresh: gate), + source, + ); + + expect(UpdateQueue.instance.pendingUserId, 'user-1', reason: '$source'); + } + }); + + test('routes through the queue so a burst can coalesce', () { + final survey = _survey( + interactionRefresh: const TInteractionRefresh( + onDisplay: true, + onResponse: true, + onFinished: true, + ), + ); + + for (final source in InteractionSource.values) { + refreshSegmentsAfterInteraction('user-1', survey, source); + } + + // All three land in one pending batch; the queue's own 500 ms debounce is + // what collapses them into a single request (covered by + // update_queue_test.dart). + expect(UpdateQueue.instance.pendingUserId, 'user-1'); + }); + }); + + group('TInteractionRefresh.fromJson', () { + test('reads all three flags', () { + final gate = TInteractionRefresh.fromJson(const { + 'onDisplay': true, + 'onResponse': false, + 'onFinished': true, + }); + + expect(gate.shouldRefresh(InteractionSource.onDisplay), isTrue); + expect(gate.shouldRefresh(InteractionSource.onResponse), isFalse); + expect(gate.shouldRefresh(InteractionSource.onFinished), isTrue); + }); + + test('treats missing flags as false rather than failing', () { + final gate = TInteractionRefresh.fromJson(const {'onDisplay': true}); + + expect(gate.onDisplay, isTrue); + expect(gate.onResponse, isFalse); + expect(gate.onFinished, isFalse); + }); + + test('treats non-boolean flags as false', () { + final gate = TInteractionRefresh.fromJson(const { + 'onDisplay': 'yes', + 'onResponse': 1, + 'onFinished': null, + }); + + expect(gate.onDisplay, isFalse); + expect(gate.onResponse, isFalse); + expect(gate.onFinished, isFalse); + }); + + test('ignores unknown keys', () { + final gate = TInteractionRefresh.fromJson(const { + 'onDisplay': true, + 'onSomethingNew': true, + }); + + expect(gate.onDisplay, isTrue); + }); + }); + + group('TSurvey.interactionRefresh', () { + test('is null when the workspace has no interaction targeting', () { + expect(TSurvey.fromJson(const {'id': 'a'}).interactionRefresh, isNull); + }); + + test('is parsed when present', () { + final survey = TSurvey.fromJson(const { + 'id': 'a', + 'interactionRefresh': {'onFinished': true}, + }); + + expect(survey.interactionRefresh, isNotNull); + expect( + survey.interactionRefresh!.shouldRefresh(InteractionSource.onFinished), + isTrue, + ); + }); + + /// Present-but-all-false is a real payload: the backend attaches it to every + /// survey in an interaction-targeting workspace. + test('all-false is present but never refreshes', () { + final survey = TSurvey.fromJson(const { + 'id': 'a', + 'interactionRefresh': { + 'onDisplay': false, + 'onResponse': false, + 'onFinished': false, + }, + }); + + expect(survey.interactionRefresh, isNotNull); + for (final source in InteractionSource.values) { + expect(survey.interactionRefresh!.shouldRefresh(source), isFalse); + } + }); + + test('a non-object value is ignored rather than throwing', () { + expect( + TSurvey.fromJson(const {'id': 'a', 'interactionRefresh': 'nope'}) + .interactionRefresh, + isNull, + ); + }); + + /// The whole survey map is re-serialized into the WebView payload, so the + /// gate has to survive the round trip for the runtime to see it. + test('survives the raw round trip into the runtime payload', () { + const json = { + 'id': 'a', + 'interactionRefresh': {'onDisplay': true}, + }; + + expect(TSurvey.fromJson(json).toJson()['interactionRefresh'], { + 'onDisplay': true, + }); + }); + }); +} diff --git a/packages/formbricks/test/widgets/survey_html_test.dart b/packages/formbricks/test/widgets/survey_html_test.dart index 438d2df..bb1fdde 100644 --- a/packages/formbricks/test/widgets/survey_html_test.dart +++ b/packages/formbricks/test/widgets/survey_html_test.dart @@ -54,6 +54,41 @@ void main() { expect(html, contains('"isWebEnvironment":false')); }); + test('bridges onFinished and hands it to renderSurvey', () { + final html = buildSurveyHtml(_opts()); + + expect(html, contains('function onFinished()')); + expect(html, contains('postFormbricksMessage({ onFinished: true })')); + + // Defining the shim is not enough — the runtime only calls it if it is + // listed in the props object, so assert it inside that block. + final propsBlock = html.substring( + html.indexOf('const surveyProps = {'), + html.indexOf('const runtime = window.formbricksSurveys'), + ); + expect(propsBlock, contains('onFinished,')); + expect(propsBlock, contains('onDisplayCreated,')); + expect(propsBlock, contains('onResponseCreated,')); + expect(propsBlock, contains('onClose,')); + }); + + test('forwards the interaction-refresh gate to the runtime payload', () { + final html = buildSurveyHtml( + _opts( + survey: _survey({ + 'interactionRefresh': { + 'onDisplay': true, + 'onResponse': false, + 'onFinished': true, + }, + }), + ), + ); + + expect(html, contains('"interactionRefresh"')); + expect(html, contains('"onFinished":true')); + }); + test('script and runtime failures close the survey route', () { final html = buildSurveyHtml(_opts()); const timeoutClose = diff --git a/packages/formbricks/test/widgets/survey_webview_test.dart b/packages/formbricks/test/widgets/survey_webview_test.dart index 43c90a6..0359aa6 100644 --- a/packages/formbricks/test/widgets/survey_webview_test.dart +++ b/packages/formbricks/test/widgets/survey_webview_test.dart @@ -7,6 +7,7 @@ import 'package:formbricks/src/common/config.dart'; import 'package:formbricks/src/common/logger.dart'; import 'package:formbricks/src/survey/survey_store.dart'; import 'package:formbricks/src/types/survey.dart'; +import 'package:formbricks/src/user/update_queue.dart'; import 'package:formbricks/src/widgets/survey_webview.dart'; import 'package:formbricks/src/widgets/webview_event.dart'; import 'package:formbricks/src/widgets/webview_navigation.dart'; @@ -63,6 +64,7 @@ class _TappableHost { Future _seedConfig({ String? language, + String? userId, Map settings = const {}, List> surveys = const [], List> filteredSurveys = const [], @@ -83,7 +85,10 @@ Future _seedConfig({ }, 'user': { 'expiresAt': null, - 'data': {if (language != null) 'language': language}, + 'data': { + if (language != null) 'language': language, + if (userId != null) 'userId': userId, + }, }, 'filteredSurveys': filteredSurveys, 'status': {'value': 'success', 'expiresAt': null}, @@ -251,6 +256,80 @@ void main() { expect(find.byKey(_stub), findsNothing); }); + group('interaction-based segment refresh', () { + setUp(UpdateQueue.resetInstance); + + testWidgets('FinishedEvent nudges the refresh when the gate allows it', + (tester) async { + await _seedConfig(userId: 'user-1'); + final host = await _present( + tester, + _survey({ + 'id': 's1', + 'languages': [], + 'interactionRefresh': {'onFinished': true}, + }), + ); + + host.onEvent!(const FinishedEvent()); + + expect(UpdateQueue.instance.pendingUserId, 'user-1'); + // Cancel the debounced flush so no timer outlives the test. + UpdateQueue.resetInstance(); + }); + + testWidgets('DisplayCreatedEvent nudges the refresh when flagged', + (tester) async { + await _seedConfig(userId: 'user-1'); + final host = await _present( + tester, + _survey({ + 'id': 's1', + 'languages': [], + 'interactionRefresh': {'onDisplay': true}, + }), + ); + + host.onEvent!(const DisplayCreatedEvent()); + + expect(UpdateQueue.instance.pendingUserId, 'user-1'); + UpdateQueue.resetInstance(); + }); + + testWidgets('a closed gate makes every event a no-op', (tester) async { + await _seedConfig(userId: 'user-1'); + final host = await _present( + tester, + _survey({'id': 's1', 'languages': []}), + ); + + host.onEvent!(const FinishedEvent()); + host.onEvent!(const DisplayCreatedEvent()); + + expect(UpdateQueue.instance.isEmpty, isTrue); + }); + + testWidgets('the same source refreshes at most once per showing', + (tester) async { + await _seedConfig(userId: 'user-1'); + final host = await _present( + tester, + _survey({ + 'id': 's1', + 'languages': [], + 'interactionRefresh': {'onFinished': true}, + }), + ); + + host.onEvent!(const FinishedEvent()); + expect(UpdateQueue.instance.pendingUserId, 'user-1'); + + UpdateQueue.resetInstance(); + host.onEvent!(const FinishedEvent()); + expect(UpdateQueue.instance.isEmpty, isTrue); + }); + }); + testWidgets( 'DisplayCreatedEvent records a display + lastDisplayAt + persists', (tester) async { diff --git a/packages/formbricks/test/widgets/webview_event_test.dart b/packages/formbricks/test/widgets/webview_event_test.dart index 0cc2ef7..3845976 100644 --- a/packages/formbricks/test/widgets/webview_event_test.dart +++ b/packages/formbricks/test/widgets/webview_event_test.dart @@ -62,7 +62,28 @@ void main() { }); test('well-formed but non-actionable payload → empty (quiet)', () { - expect(parseWebViewEvents('{"onFinished":true}'), isEmpty); + expect(parseWebViewEvents('{}'), isEmpty); + expect(parseWebViewEvents('{"onFinished":false}'), isEmpty); + }); + + test('onFinished maps to a FinishedEvent', () { + expect( + parseWebViewEvents('{"onFinished":true}'), + [isA()], + ); + }); + + test('finished is emitted after response, in handler order', () { + expect( + parseWebViewEvents( + '{"onResponseCreated":true,"onFinished":true,"onClose":true}', + ), + [ + isA(), + isA(), + isA(), + ], + ); }); test('file-pick payload is ignored by the Flutter bridge', () { From 519f31055a655486177198aff5972c17ea1f179b Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Wed, 5 Aug 2026 18:36:33 +0530 Subject: [PATCH 2/2] fix: handle the failing flush from the fire-and-forget refresh MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found while addressing the same finding on the React Native SDK, which shares this queue design. `processUpdates()` completes with an error when the flush throws, and the refresh calls it fire-and-forget. `unawaited` marks a future as intentionally not awaited but does not handle its errors, so a failing flush surfaced as an unhandled async error. Attach `catchError`; the queue already logs the real cause, so swallowing here avoids reporting it twice. The test drives a real failing flush inside `runZonedGuarded` and asserts nothing reaches the zone's error handler — it fails without the `catchError`. --- .../lib/src/user/interaction_refresh.dart | 6 +++- .../test/user/interaction_refresh_test.dart | 30 +++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/packages/formbricks/lib/src/user/interaction_refresh.dart b/packages/formbricks/lib/src/user/interaction_refresh.dart index 1c2ec08..a75f81b 100644 --- a/packages/formbricks/lib/src/user/interaction_refresh.dart +++ b/packages/formbricks/lib/src/user/interaction_refresh.dart @@ -36,5 +36,9 @@ void refreshSegmentsAfterInteraction( final queue = UpdateQueue.instance; queue.updateUserId(userId); - unawaited(queue.processUpdates()); + // `processUpdates` completes with an error when the flush throws, and this is + // fire-and-forget — `unawaited` marks the future as intentionally not awaited but + // does not handle its errors, so one would surface as an unhandled async error. + // The queue already logs the real cause, so swallow it rather than report twice. + unawaited(queue.processUpdates().catchError((Object _) {})); } diff --git a/packages/formbricks/test/user/interaction_refresh_test.dart b/packages/formbricks/test/user/interaction_refresh_test.dart index f58a522..b25b020 100644 --- a/packages/formbricks/test/user/interaction_refresh_test.dart +++ b/packages/formbricks/test/user/interaction_refresh_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:formbricks/src/types/survey.dart'; import 'package:formbricks/src/user/interaction_refresh.dart'; @@ -110,6 +112,34 @@ void main() { } }); + /// The flush is fire-and-forget, so an error on the returned future would otherwise + /// surface as an unhandled async error and, in a test zone, fail the test. + test('a failing flush does not escape as an unhandled async error', + () async { + final failures = []; + + await runZonedGuarded( + () async { + refreshSegmentsAfterInteraction( + 'user-1', + _survey( + interactionRefresh: const TInteractionRefresh(onDisplay: true), + ), + InteractionSource.onDisplay, + ); + + // Force the queued flush to fail: no appUrl/workspaceId is configured, so + // `_flush` throws once the debounce elapses. + await Future.delayed( + UpdateQueue.debounceDelay + const Duration(milliseconds: 100), + ); + }, + (error, _) => failures.add(error), + ); + + expect(failures, isEmpty); + }); + test('routes through the queue so a burst can coalesce', () { final survey = _survey( interactionRefresh: const TInteractionRefresh(