Skip to content

feat: support survey-interaction segment filters (ENG-1275) - #38

Merged
pandeymangg merged 2 commits into
mainfrom
feat/interaction-based-segments
Aug 5, 2026
Merged

feat: support survey-interaction segment filters (ENG-1275)#38
pandeymangg merged 2 commits into
mainfrom
feat/interaction-based-segments

Conversation

@pandeymangg

Copy link
Copy Markdown
Contributor

What & why

The web app now supports survey-interaction segment filters — targeting contacts by whether they have seen / have not seen / have started responding to / have completed / have not completed a survey within a time window (formbricks#8588).

Membership for those filters is computed server-side, and it can flip the moment a contact interacts with a survey. js-core reacts by refetching user state right away; this SDK had no equivalent, so it kept using the segment list it received at app launch. A rule like "completed survey A → show survey B" would not fire in the same session.

What changed

The gateTInteractionRefresh + TSurvey.interactionRefresh

"interactionRefresh": { "onDisplay": true, "onResponse": false, "onFinished": true }

Absent for workspaces that don't use interaction targeting, and present-but-all-false for surveys no interaction filter references — both handled.

TInteractionRefresh.fromJson is deliberately tolerant: each flag is read as json['x'] == true, so a missing or non-boolean value becomes false rather than throwing. A strict parser would turn one malformed object into a failed workspace-state decode, and the failure mode there is a silent total blackout — no surveys at all.

Nothing extra was needed to get the gate to the survey runtime: TSurvey.toJson returns the original decoded map verbatim, so the field round-trips into the WebView payload for free. There's a test pinning that.

A new bridge eventFinishedEvent

onFinished was already in _hasValidShape's flag list, and the doc comment on parseWebViewEvents even called it out as "a well-formed but non-actionable payload". So the payload was accepted and then dropped — haveCompleted / haveNotCompleted had no client-side trigger. Now it maps to a FinishedEvent, emitted in handler order between response and open-external-url.

The harness posts it too. Because the harness passes getSetIsResponseSendingFinished, the runtime gates onFinished on the response actually being accepted by the backend, so it means completed and persisted rather than merely "the UI finished".

The refreshlib/src/user/interaction_refresh.dart

Gated twice, because a /user sync is not cheap:

  • no-op for anonymous users (and for an empty user id), who never receive segments in the first place
  • no-op unless the server set the bit for that survey and that event

Routed through UpdateQueue so a display → response → finish burst debounces into a single request. A per-showing Set<InteractionSource> on the State means a repeated event cannot cost a second request — the runtime guards onResponseCreated itself but not onFinished, and a self-hosted server may serve an older bundle.

Verification

307 tests, 0 failures. dart analyze and dart format both clean.

New coverage: gate behaviour and fromJson tolerance in test/user/interaction_refresh_test.dart, event parsing in webview_event_test.dart, the harness in survey_html_test.dart, and widget-level wiring in survey_webview_test.dart (driving real bridge events through the existing stub host).

Confirmed with mutation testing — each of these makes tests go red:

Mutation Tests failing
remove onFinished from the surveyProps object 1
stop emitting FinishedEvent 2
case FinishedEvent()break 2
remove the anonymous-user guard 5
ignore the gate and always refresh 4
never parse the gate 5

Notes for reviewers

  1. One existing test changed behaviour deliberately. webview_event_test.dart's "well-formed but non-actionable payload → empty (quiet)" used {"onFinished":true} as its example of a payload that yields nothing. That is now actionable, so the test moved to {} / {"onFinished":false} and two new cases were added for the event itself. The stale doc comment on parseWebViewEvents was updated to match.
  2. No TTL bug here, unlike the native SDKs. expiry_ticker extends the user-state expiry rather than refetching — same as js-core. That is by design, and it is exactly why the interaction refresh matters: nothing else pulls fresh segments mid-session.
  3. Known gap, deliberately left alone. onResponseCreated fires optimistically from the surveys runtime, before the response-create POST completes, so an interaction-driven /user refresh can land before the row is committed and the server's array wins. js-core has the same characteristic, so this matches it rather than diverging. The real fix is upstream — moving onResponseCreated onto the response queue's confirmed hook — which fixes every platform at once.

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.
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 67d5c155-ed77-4df7-8e4f-77b7d8c3404b

📥 Commits

Reviewing files that changed from the base of the PR and between e756cd2 and fc31499.

📒 Files selected for processing (9)
  • packages/formbricks/lib/src/types/survey.dart
  • packages/formbricks/lib/src/user/interaction_refresh.dart
  • packages/formbricks/lib/src/widgets/survey_html.dart
  • packages/formbricks/lib/src/widgets/survey_webview.dart
  • packages/formbricks/lib/src/widgets/webview_event.dart
  • packages/formbricks/test/user/interaction_refresh_test.dart
  • packages/formbricks/test/widgets/survey_html_test.dart
  • packages/formbricks/test/widgets/survey_webview_test.dart
  • packages/formbricks/test/widgets/webview_event_test.dart

Walkthrough

The change adds optional interaction-refresh settings to surveys. It adds display, response, and completion interaction sources with JSON parsing and refresh gating. Survey WebView HTML now forwards completion events, and the event parser emits FinishedEvent. SurveyWebView performs one gated segment refresh per interaction source through UpdateQueue. Tests cover configuration parsing, HTML callbacks, event ordering, refresh eligibility, queue processing, and per-showing deduplication.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies support for survey-interaction segment filters, which is the primary change in the pull request.
Description check ✅ Passed The description accurately explains the interaction-refresh gate, FinishedEvent handling, segment refresh behavior, testing, and known limitations.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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`.
@sonarqubecloud

sonarqubecloud Bot commented Aug 5, 2026

Copy link
Copy Markdown

@pandeymangg
pandeymangg requested a review from Dhruwang August 5, 2026 15:15
@pandeymangg
pandeymangg merged commit b18c0cc into main Aug 5, 2026
10 checks passed
@pandeymangg pandeymangg mentioned this pull request Aug 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants