feat: support survey-interaction segment filters (ENG-1275) - #38
Conversation
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.
|
No actionable comments were generated in the recent review. 🎉 ℹ️ Recent review info⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (9)
WalkthroughThe 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 🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
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. Comment |
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`.
|



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-corereacts 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 gate —
TInteractionRefresh+TSurvey.interactionRefreshAbsent for workspaces that don't use interaction targeting, and present-but-all-false for surveys no interaction filter references — both handled.
TInteractionRefresh.fromJsonis deliberately tolerant: each flag is read asjson['x'] == true, so a missing or non-boolean value becomesfalserather 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.toJsonreturns 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 event —
FinishedEventonFinishedwas already in_hasValidShape's flag list, and the doc comment onparseWebViewEventseven called it out as "a well-formed but non-actionable payload". So the payload was accepted and then dropped —haveCompleted/haveNotCompletedhad no client-side trigger. Now it maps to aFinishedEvent, emitted in handler order between response and open-external-url.The harness posts it too. Because the harness passes
getSetIsResponseSendingFinished, the runtime gatesonFinishedon the response actually being accepted by the backend, so it means completed and persisted rather than merely "the UI finished".The refresh —
lib/src/user/interaction_refresh.dartGated twice, because a
/usersync is not cheap:Routed through
UpdateQueueso a display → response → finish burst debounces into a single request. A per-showingSet<InteractionSource>on theStatemeans a repeated event cannot cost a second request — the runtime guardsonResponseCreateditself but notonFinished, and a self-hosted server may serve an older bundle.Verification
307 tests, 0 failures.dart analyzeanddart formatboth clean.New coverage: gate behaviour and
fromJsontolerance intest/user/interaction_refresh_test.dart, event parsing inwebview_event_test.dart, the harness insurvey_html_test.dart, and widget-level wiring insurvey_webview_test.dart(driving real bridge events through the existing stub host).Confirmed with mutation testing — each of these makes tests go red:
onFinishedfrom thesurveyPropsobjectFinishedEventcase FinishedEvent()→breakNotes for reviewers
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 onparseWebViewEventswas updated to match.expiry_tickerextends 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.onResponseCreatedfires optimistically from the surveys runtime, before the response-create POST completes, so an interaction-driven/userrefresh 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 — movingonResponseCreatedonto the response queue's confirmed hook — which fixes every platform at once.