Skip to content

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

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

feat: support survey-interaction segment filters (ENG-1275)#52
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. The web SDK reacts by refetching user state right away. iOS had no equivalent, so it kept using the segment list it received at app launch — meaning a rule like "completed survey A → show survey B" would not fire in the same session.

This ports the client half of that change.

What changed

The gateinteractionRefresh

The client API now attaches a per-survey object saying whether interacting with that survey can change any live survey's membership:

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

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

InteractionRefresh's decoder is deliberately tolerant: a partial object reads missing flags as false instead of throwing. A strict model would turn one malformed object into a keyNotFound on the whole workspace-state decode, and the failure mode there is a silent total blackoutfilteredSurveys stays empty and the user sees no surveys at all.

The missing completion signalonFinished

onFinished has always been a prop of the surveys library, but iOS never passed it in, so haveCompleted / haveNotCompleted had no client-side trigger at all. Added as a new EventType case, a JS shim, and a native handler.

Because iOS passes getSetIsResponseSendingFinished, isResponseSendingFinished starts false, so on app surveys onFinished genuinely means the finished response was accepted by the backend — not merely "the UI finished". No change to the surveys bundle is needed.

The refresh

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 server set the bit for that survey and that event

Routed through UpdateQueue rather than calling syncUser directly, so a display → response → finish burst debounces into a single request. A per-showing guard means a repeated event can't cost a second request.

UpdateQueue in-flight join

APIClient does not serialise requests, so two concurrent POST /user calls could race and whichever response landed last would overwrite segments / displays / responses wholesale. A refresh nudge now joins an in-flight sync instead of starting a second one.

Bug fix: the user-state sync timer never fired

Worth reading separately — it's pre-existing, and this feature depends on it.

startSyncTimer() used Timer.scheduledTimer, which installs on RunLoop.current. It is called from inside syncUser's completion, which APIClient delivers on URLSession's background delegate queue — a pooled thread with no run loop. The timer was created, retained, and silently never fired.

Net effect: user state (segments, displays, responses) was frozen for the whole app session. The only working refresh was the lazy check inside Formbricks.setup(), which runs once per process. There is no foreground observer either.

The same file's sibling timer already hops to main (UpdateQueue.startDebounceTimer), so this was an oversight rather than a design choice.

Now:

  • built unscheduled and added to RunLoop.main in .common mode, so it isn't postponed while the user is scrolling
  • interval clamped to a floor, so a device clock running ahead of the server can't cause a tight re-sync loop (every expiresAt would land in the device's past)
  • the fire block re-checks the user id, so a logged-out or switched user is never re-synced

Verification

89 tests, 0 failures — 65 existing plus 24 new.

The timer fix has a real regression test: it drives a sync whose completion arrives on a background thread, mirroring APIClient, and asserts a second sync happens. Reverting startSyncTimer to Timer.scheduledTimer makes it fail with Exceeded timeout of 5 seconds.

The end-to-end wiring is covered by driving the real JsMessageHandler with a real WKScriptMessage (body is overridable, so no production seam was needed). Confirmed with mutation testing — each of these makes a test go red:

Mutation Result
.onFinished case → break 3 tests fail
remove the per-showing guard 1 test fails
first(where:)first in the survey lookup 1 test fails

Notes for reviewers

  1. Forward compatibility of already-shipped binaries was checked separately. Each released tag's models were compiled and fed a production-shaped payload with and without interactionRefresh — identical decoded object graph every time. Swift's synthesised Codable ignores unknown keys and nothing here overrides that. Old apps are unaffected.
  2. Config.User.minimumSyncIntervalInSeconds is a var purely so tests can shorten it. The SDK never writes to it. Happy to swap it for a different injection point if you'd rather.
  3. Known gap, deliberately left alone. onResponseCreated fires optimistically from the surveys library, before the response-create POST completes, and syncUser replaces responses / displays wholesale. So an interaction-driven sync can drop a just-made local append, which could let a displayMultiple survey re-display later in the session. The web SDK has the same characteristic, so this matches it rather than diverging. The real fix is upstream — moving onResponseCreated onto ResponseQueue's confirmed hook — which fixes both platforms at once.
  4. Not bumped the podspec version, since releases look like separate commits in this repo. Say the word if you want it in here.

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.

- Decode the new per-survey `interactionRefresh` gate from the workspace-state
  payload. The decoder is deliberately tolerant: a partial object reads missing
  flags as false rather than failing the whole payload, which would leave the
  user with no surveys at all.
- Add the missing `onFinished` bridge event. The surveys library has always
  exposed the callback, but it was never passed in, so "have completed X" had no
  client-side trigger. Because we pass `getSetIsResponseSendingFinished`, this
  fires only after the finished response is accepted by the backend.
- Refresh user state after a display, response or finish, gated twice: no-op for
  anonymous users, and no-op unless the server flagged that survey and event.
  Routed through the UpdateQueue so a display -> response -> finish burst is
  debounced into one request.
- Give UpdateQueue an in-flight join. APIClient does not serialise requests, so
  two concurrent POST /user calls could race and the later response would
  overwrite segments, displays and responses wholesale.

Also fixes a pre-existing bug this feature depends on: the user-state sync timer
never fired. `startSyncTimer` ran inside `syncUser`'s completion, which APIClient
delivers on URLSession's background delegate queue, and `Timer.scheduledTimer`
installs on `RunLoop.current` — a pooled thread with no run loop. The timer was
created and silently never ran, so user state was only ever refreshed by the
lazy check inside `setup()`. It is now built unscheduled and added to the main
run loop, mirroring what UpdateQueue already does for its debounce timer. The
interval is clamped so a device clock running ahead of the server cannot cause a
tight re-sync loop, and the fire block re-checks the user id so a logged-out or
switched user is not re-synced.
@coderabbitai

coderabbitai Bot commented Aug 5, 2026

Copy link
Copy Markdown

Review Change Stack

Walkthrough

Added interaction-based survey refresh configuration and event models. JavaScript survey callbacks now report completion, display, and response events to the native bridge. The bridge deduplicates events per WebView presentation and routes matching interactions to UserManager. User synchronization now applies identity and configuration checks, queue in-flight tracking, debounce behavior, and minimum timer intervals. Tests cover decoding, event handling, queue behavior, end-to-end refreshes, and timer scheduling.

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 48.84% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly summarizes the main change: support for survey-interaction segment filters.
Description check ✅ Passed The description directly explains the survey-interaction filter support, refresh behavior, timer fix, and test coverage.
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.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
Sources/FormbricksSDK/WebView/FormbricksViewModel.swift (1)

58-81: 📐 Maintainability & Code Quality | 🔵 Trivial

Remove the completion refresh event from the JS bridge contract.

onFinished and isResponseSendingFinished are implementation details, not a public SDK API. If an older self-hosted bundle lacks this prop, this code silently disables the on-finish refresh path. Use the supported completion-flow mechanism, such as configured post-survey actions/webhooks, or document and pin the exact surveys bundle version that exposes it.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@Sources/FormbricksSDK/WebView/FormbricksViewModel.swift` around lines 58 -
81, Remove the onFinished callback and
getSetIsResponseSendingFinished/setResponseFinished wiring from the JavaScript
bridge and surveyProps construction. Do not expose isResponseSendingFinished
through this SDK contract; use the supported post-survey action or webhook
completion mechanism instead, or pin the surveys bundle to a version that
explicitly provides it.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@Sources/FormbricksSDK/Manager/UserManager.swift`:
- Around line 149-154: Re-arm the sync cycle in the `.failure` branch of
`syncUser(withId:)` after releasing the queue lock and logging the error. Add a
private `scheduleSyncRetry(for:)` helper that invalidates the stale timer,
verifies the user ID still matches, and schedules a bounded one-shot retry on
the main run loop; optionally apply increasing backoff for consecutive failures.
- Around line 197-243: Confine all syncTimer reads, writes, invalidation, and
scheduling in startSyncTimer and stopSyncTimer to the main thread by moving the
complete bookkeeping into onMain closures. Ensure startSyncTimer validates
expiresAt/userId and creates or replaces the timer inside that closure, while
stopSyncTimer clears and invalidates the property there; preserve the existing
main-thread synchronous behavior and allow background callers to dispatch
asynchronously.

In `@Sources/FormbricksSDK/Networking/Queue/UpdateQueue.swift`:
- Around line 76-96: Defer refresh requests received during an in-flight sync
instead of dropping them: add pending-refresh state managed by
requestUserStateRefresh and replay it from syncDidFinish(), including after
successful sync completion. Clear this state in reset() and cleanup() to prevent
replaying a prior user’s nudge, while preserving the existing userId handling
and debounce flow.

In `@Tests/FormbricksSDKTests/SurveyInteractionRefreshTests.swift`:
- Around line 212-247: Replace the fixed async delays in
testMatchingFlagTriggersExactlyOneSync and
testInteractionBurstCoalescesIntoOneSync with callback-driven expectations. Add
an onPostUser callback to CountingMockService, invoke it when the mock records a
post, and fulfill each test’s expectation from that callback before asserting
the call count. Leave bounded waits unchanged for tests that verify no request
occurs.

---

Outside diff comments:
In `@Sources/FormbricksSDK/WebView/FormbricksViewModel.swift`:
- Around line 58-81: Remove the onFinished callback and
getSetIsResponseSendingFinished/setResponseFinished wiring from the JavaScript
bridge and surveyProps construction. Do not expose isResponseSendingFinished
through this SDK contract; use the supported post-survey action or webhook
completion mechanism instead, or pin the surveys bundle to a version that
explicitly provides it.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: b9ef08f2-e415-4864-9fae-26e65ecaf13b

📥 Commits

Reviewing files that changed from the base of the PR and between cf2b6ad and 40b4fb0.

📒 Files selected for processing (12)
  • Sources/FormbricksSDK/Config.swift
  • Sources/FormbricksSDK/Manager/SurveyManager.swift
  • Sources/FormbricksSDK/Manager/UserManager.swift
  • Sources/FormbricksSDK/Model/Javascript/EventType.swift
  • Sources/FormbricksSDK/Model/Workspace/Survey.swift
  • Sources/FormbricksSDK/Model/Workspace/Surveys/InteractionRefresh.swift
  • Sources/FormbricksSDK/Networking/Queue/UpdateQueue.swift
  • Sources/FormbricksSDK/WebView/FormbricksViewModel.swift
  • Sources/FormbricksSDK/WebView/SurveyWebView.swift
  • Tests/FormbricksSDKTests/FormbricksSDKTests.swift
  • Tests/FormbricksSDKTests/Mock/Environment.json
  • Tests/FormbricksSDKTests/SurveyInteractionRefreshTests.swift

Comment thread Sources/FormbricksSDK/Manager/UserManager.swift
Comment thread Sources/FormbricksSDK/Manager/UserManager.swift
Comment thread Sources/FormbricksSDK/Networking/Queue/UpdateQueue.swift
Comment thread Tests/FormbricksSDKTests/SurveyInteractionRefreshTests.swift
- Confine every `syncTimer` read and write to the main thread. `startSyncTimer()`
  runs on URLSession's delegate queue while `stopSyncTimer()` can run on main, so
  the two writes raced on an unprotected `Timer?`. A lost `nil` write stranded a
  live timer that nothing could cancel afterwards. All bookkeeping now happens
  inside `onMain`, via a shared `scheduleSync(after:for:)`.

- Re-arm the timer when a sync fails. The timer that fired is spent and
  `startSyncTimer()` is otherwise only reached from a successful sync, so one
  transient network error ended the refresh cycle for the rest of the process.
  This was masked before, when the timer never fired at all. The retry backs off
  by `Config.User.retryAfterFailureInMinutes` rather than the minimum sync
  interval, so a sustained outage doesn't become a fixed-rate request stream.

- Defer and replay a refresh that arrives mid-sync instead of dropping it. The
  in-flight request was built before that interaction, so its response cannot
  reflect it — dropping the nudge left segments stale until the next trigger.
  Only one deferred refresh is kept, so many interactions behind a slow sync
  still cost a single follow-up. `syncDidFinish()` now drains it, and is called
  on the success path too.

- Drive the positive-count tests off the mock instead of fixed sleeps, so a
  loaded CI machine can't make them flake, and use `assertForOverFulfill` for
  the "exactly one request" half.

92 tests, 0 failures. Both new behaviours are mutation-checked: dropping the
failure re-arm fails `testFailedSyncReArmsTheTimer`, and dropping the deferred
nudge fails `testRefreshDuringAnInFlightSyncIsDeferredThenReplayed`.
@sonarqubecloud

sonarqubecloud Bot commented Aug 5, 2026

Copy link
Copy Markdown

@pandeymangg
pandeymangg requested a review from Dhruwang August 5, 2026 15:14
@pandeymangg
pandeymangg added this pull request to the merge queue Aug 5, 2026
Merged via the queue into main with commit 17c9b4e Aug 5, 2026
4 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