Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions Sources/FormbricksSDK/Config.swift
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
import Foundation

struct Config {
struct Environment {
/// On error, the environment will be refreshed after this amount of time (in minutes)
static let refreshStateOnErrorTimeoutInMinutes = 10
}

struct User {
/// Floor for the gap between two user-state syncs. Guards against a device clock
/// running ahead of the server, where every `expiresAt` the server returns is already
/// in the device's past and an unclamped timer would sync in a tight loop.
/// A `var` only so tests can shorten it; the SDK never writes to it.
static var minimumSyncIntervalInSeconds: TimeInterval = 60

/// How long to wait before retrying a user-state sync that failed. Deliberately much
/// longer than the minimum interval so a sustained outage doesn't turn into a
/// fixed-rate request stream. Mirrors `Environment.refreshStateOnErrorTimeoutInMinutes`.
static var retryAfterFailureInMinutes = 10
}
}
9 changes: 9 additions & 0 deletions Sources/FormbricksSDK/Manager/SurveyManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,15 @@ extension SurveyManager {
func onNewDisplay(surveyId: String) {
userManager.onDisplay(surveyId: surveyId)
}

/// Forwards an in-survey interaction so the user manager can pull fresh `segments` when
/// the server flagged this survey/source pair as able to change segment membership.
func onSurveyInteraction(surveyId: String, source: InteractionSource) {
guard let survey = workspaceResponse?.data.data.surveys?.first(where: { $0.id == surveyId }) else {
return
}
userManager.refreshSegmentsAfterInteraction(survey: survey, source: source)
}
}

// MARK: - Present and dismiss survey window -
Expand Down
111 changes: 105 additions & 6 deletions Sources/FormbricksSDK/Manager/UserManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,28 @@ final class UserManager: UserManagerSyncable {
responses = newResponses
surveyManager?.filterSurveys()
}

/// Pulls fresh server-computed `segments` after an interaction that can flip segment
/// membership, instead of waiting for the state to expire.
///
/// A `surveyInteraction` segment filter ("have seen X", "have completed X", ...) can change
/// who a contact is the moment they interact with a survey. The local bookkeeping in
/// `onDisplay` / `onResponse` keeps display caps and recontact days correct on device, but
/// segment membership is only ever computed by the server, so it has to be refetched.
///
/// 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 server set the bit for this survey and this event.
///
/// It is routed through the `UpdateQueue` rather than calling `syncUser` directly, so a
/// display -> response -> finish burst is debounced into a single request.
func refreshSegmentsAfterInteraction(survey: Survey, source: InteractionSource) {
guard let userId = userId else { return }
guard survey.interactionRefresh?.shouldRefresh(on: source) == true else { return }

Formbricks.logger?.debug("Refreshing segments after \(source.rawValue) on survey \(survey.id)")
updateQueue?.requestUserStateRefresh(userId: userId)
}

/// Syncs the user state with the server if the user id is set and the expiration date has passed.
func syncUserStateIfNeeded() {
Expand All @@ -80,6 +102,11 @@ final class UserManager: UserManagerSyncable {
backingSegments = nil
backingDisplays = nil
backingResponses = nil

// The state is still valid, but nothing has been scheduled to refresh it when it
// does expire — `startSyncTimer()` is otherwise only reached from a successful sync,
// so a launch that finds a warm cache would never refresh segments again.
startSyncTimer()
return
}

Expand Down Expand Up @@ -117,10 +144,20 @@ final class UserManager: UserManagerSyncable {
}

self?.updateQueue?.reset()
// `reset()` clears the in-flight lock, but only this drains a refresh that
// arrived while the request was out — that interaction happened after this
// response was computed, so it still needs its own sync.
self?.updateQueue?.syncDidFinish()
self?.surveyManager?.filterSurveys()
self?.startSyncTimer()
case .failure(let error):
// Release the in-flight lock so a later refresh nudge isn't swallowed, and
// replay one that arrived mid-sync. `reset()` clears the lock on the success
// path, but only this call drains a queued refresh.
self?.updateQueue?.syncDidFinish()
Formbricks.logger?.error(error)
// Re-arm, otherwise the refresh cycle ends here for the whole process.
self?.scheduleSyncRetry()
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
}
Expand All @@ -145,10 +182,9 @@ final class UserManager: UserManagerSyncable {
backingExpiresAt = nil
Formbricks.language = "default"

syncTimer?.invalidate()
syncTimer = nil
stopSyncTimer()
updateQueue?.cleanup()

// Re-filter surveys for logged out user
surveyManager?.filterSurveys()
}
Expand All @@ -165,14 +201,77 @@ final class UserManager: UserManagerSyncable {

// MARK: - Timer -
private extension UserManager {
/// Schedules the next user-state sync for when the cached state expires.
///
/// This runs inside `syncUser`'s completion, which `APIClient` delivers on URLSession's
/// background delegate queue — a pooled thread with no run loop. `Timer.scheduledTimer`
/// installs on `RunLoop.current`, so scheduling it there produced a timer that could never
/// fire, and the user state was in practice only ever refreshed by the lazy check inside
/// `Formbricks.setup()`. Build the timer unscheduled and add it to the main run loop
/// instead, the same way `UpdateQueue` hops to main for its debounce timer.
func startSyncTimer() {
guard let expiresAt = expiresAt, let id = userId else { return }
syncTimer?.invalidate()
syncTimer = Timer.scheduledTimer(withTimeInterval: expiresAt.timeIntervalSinceNow, repeats: false) { [weak self] _ in
self?.syncUser(withId: id)

// A device clock running ahead of the server makes every `expiresAt` we receive
// already in the past, which would otherwise sync in a tight loop.
let interval = max(expiresAt.timeIntervalSinceNow, Config.User.minimumSyncIntervalInSeconds)
scheduleSync(after: interval, for: id)
}

/// Re-arms the sync after a failed request.
///
/// Without this, one transient network failure ends the refresh cycle for the rest of the
/// process: the timer that fired is spent, and `startSyncTimer()` is otherwise only reached
/// from a successful sync. `expiresAt` still holds the value from the last success, so it is
/// not a usable cadence here — back off by the same interval the workspace-state path uses
/// for its errors instead of retrying at the minimum sync interval.
func scheduleSyncRetry() {
guard let id = userId else { return }
scheduleSync(after: Double(Config.User.retryAfterFailureInMinutes) * 60.0, for: id)
}

/// Cancels a pending user-state sync. Safe to call from any thread.
func stopSyncTimer() {
onMain { [weak self] in
self?.syncTimer?.invalidate()
self?.syncTimer = nil
}
}

/// Replaces any pending sync with one scheduled `interval` from now.
///
/// Every read and write of `syncTimer` happens inside `onMain`. `startSyncTimer()` is called
/// from `syncUser`'s completion on URLSession's background delegate queue while
/// `stopSyncTimer()` can run from the main thread, so leaving the property unsynchronised
/// let the two writes race — and a lost `nil` write strands a live timer that nothing can
/// cancel afterwards.
func scheduleSync(after interval: TimeInterval, for id: String) {
onMain { [weak self] in
guard let self = self else { return }
self.syncTimer?.invalidate()

let timer = Timer(timeInterval: interval, repeats: false) { [weak self] _ in
// The user may have been logged out or swapped while this was pending.
guard let self = self, self.userId == id else { return }
self.syncUser(withId: id)
}
self.syncTimer = timer

// `.common` so an expiry that lands mid-scroll isn't postponed until the gesture ends.
RunLoop.main.add(timer, forMode: .common)
}
}

/// Runs `work` on the main thread, immediately if we are already there. `Timer` and
/// `RunLoop` are bound to the thread that scheduled them, so all timer bookkeeping has to
/// funnel through the main run loop.
func onMain(_ work: @escaping () -> Void) {
if Thread.isMainThread {
work()
} else {
DispatchQueue.main.async(execute: work)
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}

// MARK: - Getters -
Expand Down
1 change: 1 addition & 0 deletions Sources/FormbricksSDK/Model/Javascript/EventType.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ enum EventType: String, Codable {
case onClose = "onClose"
case onDisplayCreated = "onDisplayCreated"
case onResponseCreated = "onResponseCreated"
case onFinished = "onFinished"
case onOpenExternalURL = "onOpenExternalURL"
case onSurveyLibraryLoadError = "onSurveyLibraryLoadError"
}
3 changes: 3 additions & 0 deletions Sources/FormbricksSDK/Model/Workspace/Survey.swift
Original file line number Diff line number Diff line change
Expand Up @@ -65,4 +65,7 @@ struct Survey: Codable {
let styling: Styling?
let languages: [SurveyLanguage]?
let projectOverwrites: ProjectOverwrites?
/// Whether interacting with this survey can change some live survey's segment
/// membership. Absent unless the workspace uses survey-interaction targeting.
let interactionRefresh: InteractionRefresh?
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
import Foundation

/// The three survey-lifecycle moments that can flip interaction-based segment membership.
/// Raw values match the source names used by the JS SDK so the gate below can be keyed
/// off the same vocabulary on both platforms.
enum InteractionSource: String {
case onDisplay
case onResponse
case 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 — e.g. a survey referenced only by a "have seen"
/// filter refreshes on display but not on response or finish, and a survey no interaction
/// filter references 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 points at.
struct InteractionRefresh: Codable, Equatable {
let onDisplay: Bool
let onResponse: Bool
let onFinished: Bool

private enum CodingKeys: String, CodingKey {
case onDisplay, onResponse, onFinished
}

init(onDisplay: Bool = false, onResponse: Bool = false, onFinished: Bool = false) {
self.onDisplay = onDisplay
self.onResponse = onResponse
self.onFinished = onFinished
}

/// Missing sub-keys decode as `false`, mirroring the tolerant `Segment` decoder. A strict
/// model would turn a partial object into a `keyNotFound` on the whole workspace-state
/// decode, which blanks out every survey — so never require these keys.
init(from decoder: Decoder) throws {
let container = try decoder.container(keyedBy: CodingKeys.self)
onDisplay = try container.decodeIfPresent(Bool.self, forKey: .onDisplay) ?? false
onResponse = try container.decodeIfPresent(Bool.self, forKey: .onResponse) ?? false
onFinished = try container.decodeIfPresent(Bool.self, forKey: .onFinished) ?? false
}

/// Whether an interaction of this kind should trigger a user-state refresh.
func shouldRefresh(on source: InteractionSource) -> Bool {
switch source {
case .onDisplay: return onDisplay
case .onResponse: return onResponse
case .onFinished: return onFinished
}
}
}
67 changes: 64 additions & 3 deletions Sources/FormbricksSDK/Networking/Queue/UpdateQueue.swift
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,16 @@ final class UpdateQueue {
private var attributes: [String : AttributeValue]?
private var language: String?
private var timer: Timer?

/// True while a commit-triggered sync is airborne. A repeat nudge joins that request
/// instead of starting a second one: `APIClient` does not serialise requests, so two
/// concurrent `POST /user` calls would race and whichever response landed last would
/// overwrite `segments` / `displays` / `responses` wholesale.
private var isSyncInFlight = false
/// A refresh that arrived while a sync was already airborne, replayed once that sync
/// finishes. The in-flight request was built *before* this interaction, so its response
/// cannot reflect it — dropping the nudge would leave segments stale until the next trigger.
private var pendingRefreshUserId: String?

private weak var userManager: UserManagerSyncable?

init(userManager: UserManagerSyncable) {
Expand Down Expand Up @@ -68,11 +77,47 @@ final class UpdateQueue {
}
}

/// Asks for the user state to be re-read from the server. Carries no new data — it exists
/// so an interaction that can change segment membership doesn't have to wait for the state
/// to expire.
///
/// While a sync is airborne the nudge is deferred rather than sent, because two concurrent
/// `POST /user` calls would race and the later response would overwrite `segments` /
/// `displays` / `responses` wholesale. It is replayed by `syncDidFinish()`.
func requestUserStateRefresh(userId: String) {
syncQueue.sync {
guard !isSyncInFlight else {
Formbricks.logger?.debug("UpdateQueue - refresh deferred, a sync is already in flight")
pendingRefreshUserId = userId
return
}
self.userId = userId
startDebounceTimer()
}
}

/// Called by the user manager once a sync finishes. Releases the in-flight lock and replays
/// a refresh that arrived while the request was out.
func syncDidFinish() {
var deferredUserId: String?
syncQueue.sync {
isSyncInFlight = false
deferredUserId = pendingRefreshUserId
pendingRefreshUserId = nil
}

guard let deferredUserId = deferredUserId else { return }
Formbricks.logger?.debug("UpdateQueue - replaying a refresh that arrived mid-sync")
// Outside the block above: `requestUserStateRefresh` takes the same queue.
requestUserStateRefresh(userId: deferredUserId)
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

func reset() {
syncQueue.sync {
userId = nil
attributes = nil
language = nil
isSyncInFlight = false
}
}

Expand Down Expand Up @@ -103,16 +148,29 @@ private extension UpdateQueue {
syncQueue.sync {
effectiveUserId = self.userId ?? Formbricks.userManager?.userId
effectiveAttributes = self.attributes
// Only mark a sync in flight when one is actually about to be sent. The guard
// below decides that, so mirror its condition here — otherwise an anonymous
// commit would leave the flag stuck and swallow every later refresh nudge.
if effectiveUserId != nil {
isSyncInFlight = true
}
}

guard let userId = effectiveUserId else {
let error = FormbricksSDKError(type: .userIdIsNotSetYet)
Formbricks.logger?.error(error.message)
return
}

// Nothing will call `syncDidFinish()` if there is no user manager left to run the
// request, so clear the flag here rather than leaving it stuck.
guard let userManager = userManager else {
syncQueue.sync { isSyncInFlight = false }
return
}

Formbricks.logger?.debug("UpdateQueue - commit() called on UpdateQueue with \(userId) and \(effectiveAttributes ?? [:])")
userManager?.syncUser(withId: userId, attributes: effectiveAttributes)
userManager.syncUser(withId: userId, attributes: effectiveAttributes)
}
}

Expand All @@ -125,6 +183,9 @@ extension UpdateQueue {
userId = nil
attributes = nil
language = nil
isSyncInFlight = false
// Teardown, unlike `reset()`: drop the deferred refresh instead of replaying it.
pendingRefreshUserId = nil
}
}
}
8 changes: 8 additions & 0 deletions Sources/FormbricksSDK/WebView/FormbricksViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,13 @@ private extension FormbricksViewModel {
window.webkit.messageHandlers.jsMessage.postMessage(JSON.stringify({ event: "onResponseCreated" }));
};

// Fires once the finished response has been accepted by the backend — the
// surveys library gates this on `isResponseSendingFinished`, and we supply
// `getSetIsResponseSendingFinished` below, so it starts out false.
function onFinished() {
window.webkit.messageHandlers.jsMessage.postMessage(JSON.stringify({ event: "onFinished" }));
};

function onOpenExternalURL(url) {
window.webkit.messageHandlers.jsMessage.postMessage(JSON.stringify({ event: "onOpenExternalURL", onOpenExternalURLParams: { url: url } }));
};
Expand All @@ -71,6 +78,7 @@ private extension FormbricksViewModel {
getSetIsResponseSendingFinished,
onDisplayCreated,
onResponseCreated,
onFinished,
onClose,
onOpenExternalURL,
};
Expand Down
Loading
Loading