From 2b6eabc9d309ecc92da976a6e79464d90db3d33a Mon Sep 17 00:00:00 2001 From: Javi Aguilar <122741+itsjavi@users.noreply.github.com> Date: Fri, 28 Aug 2026 13:02:43 +0000 Subject: [PATCH 1/4] feat: setEmbeddedData() + clearEmbeddedData() for app surveys [ENG-2472] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A host app can attach context to future responses without tying it to a trigger. `track()` takes a name and nothing else, so today the only way to get context onto a response is to declare it in the survey and have the respondent type it. Formbricks.setEmbeddedData(["screen": "checkout", "plan": "pro"]) Formbricks.setEmbeddedData(["screen": nil]) // remove one key Formbricks.clearEmbeddedData("plan") // same, explicitly Formbricks.clearEmbeddedData() // everything Merge, never replace, so refreshing a volatile field cannot wipe a stable one. `nil` removes a key; a key left out is untouched, which is how a host skips a field it has no value for this screen. The single-key and clear-everything forms are separate overloads, so a non-optional `String` parameter means a host reading the key from its own state cannot accidentally wipe the bag. In-memory and never persisted: persisting would blur the Embedded Data ↔ contact-attribute boundary and create a PII-at-rest surface. Cleared on an identity switch, on logout and on cleanup() so one user's context cannot ride onto the next user's responses on a shared device; kept on first identification, because a host legitimately pushes context before it knows who the user is. Callable before setup(), unlike every other public method: a host that pushes context at launch must not have the value dropped because initialization had not finished. Snapshotted in WebViewData's initializer, which runs when the survey is actually presented after any configured delay, and frozen for its lifetime. The bag rides the props payload that already exists, under `hiddenFieldsRecord` — no new bridge message, and deliberately so: a setEmbeddedData after display must not reach the survey on screen. It is passed raw and unfiltered, because the ingest contract lives in the renderer and the server re-runs all of it. A non-finite number is logged and skipped: JSONSerialization throws on one, and the payload it would refuse is the whole survey's props blob, so a single bad value would cost the survey rather than the field. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018L6PrECN38Jbe1FEdFccTz --- Sources/FormbricksSDK/Formbricks.swift | 69 +++++ .../Manager/EmbeddedDataManager.swift | 92 +++++++ .../EmbeddedData/EmbeddedDataValue.swift | 72 +++++ .../WebView/FormbricksViewModel.swift | 6 + .../EmbeddedDataTests.swift | 258 ++++++++++++++++++ 5 files changed, 497 insertions(+) create mode 100644 Sources/FormbricksSDK/Manager/EmbeddedDataManager.swift create mode 100644 Sources/FormbricksSDK/Model/EmbeddedData/EmbeddedDataValue.swift create mode 100644 Tests/FormbricksSDKTests/EmbeddedDataTests.swift diff --git a/Sources/FormbricksSDK/Formbricks.swift b/Sources/FormbricksSDK/Formbricks.swift index 0a30b16..42d8e5d 100644 --- a/Sources/FormbricksSDK/Formbricks.swift +++ b/Sources/FormbricksSDK/Formbricks.swift @@ -135,6 +135,11 @@ import Network if let existing = userManager?.userId, !existing.isEmpty { logger?.debug("Different userId is being set, cleaning up previous user state") userManager?.logout() + // An identity switch: the ambient Embedded Data bag may carry the previous user's + // context, which must not ride onto the next user's responses on a shared device. + // First-time identification keeps the bag — a host legitimately pushes context before + // it knows who the user is. + EmbeddedDataManager.shared.removeAll() } userManager?.set(userId: userId) @@ -279,6 +284,63 @@ import Network } + /** + Attaches Embedded Data to future responses without tying it to a trigger. + + Merges into an in-memory bag — last write wins per key, and an explicit `nil` removes a key. + Values land only on the survey's declared *ingested* fields; anything else is dropped and + logged by the survey renderer, never fatal. + + Deliberately callable **before** `setup(with:)`, unlike the methods above: a host that pushes + context at launch must not have that value silently dropped because initialization had not + finished. The bag is pure memory — nothing here needs the SDK to be running. + + The bag is snapshotted when a survey is displayed and frozen for its lifetime, so a value set + while a survey is on screen reaches the *next* response, not that one. It is never persisted: + a cold app start begins empty and the host re-pushes. + + Example: + ```swift + Formbricks.setEmbeddedData([ + "plan": "pro", + "seats": 25, + "isTrial": false, + "screen": nil, // removes the key + ]) + ``` + */ + public static func setEmbeddedData(_ data: [String: EmbeddedDataValue?]) { + EmbeddedDataManager.shared.set(data) + } + + /** + Removes one Embedded Data key. A key that was never set is a no-op. + + The single-key and clear-everything forms are separate overloads on purpose: a `String` that + cannot be `nil` means a host reading the key from its own state cannot accidentally wipe the + whole bag. + + Example: + ```swift + Formbricks.clearEmbeddedData("plan") + ``` + */ + public static func clearEmbeddedData(_ key: String) { + EmbeddedDataManager.shared.remove(key: key) + } + + /** + Clears the whole Embedded Data bag — logout, or a hard context switch. + + Example: + ```swift + Formbricks.clearEmbeddedData() + ``` + */ + public static func clearEmbeddedData() { + EmbeddedDataManager.shared.removeAll() + } + /** Logs out the current user. This will clear the user attributes and the user id. The SDK must be initialized before calling this method. @@ -296,6 +358,9 @@ import Network } userManager?.logout() + // Same identity-switch rule as setUserId: logout must not let the previous user's ambient + // context leak onto whoever uses the app next. + EmbeddedDataManager.shared.removeAll() } /** @@ -332,6 +397,10 @@ import Network } private static func performCleanup() { + // An explicit full reset of the SDK, so the host-supplied context goes too. This is a + // stronger teardown than the JS SDK's internal one, which deliberately preserves the bag + // across a setup retry — `cleanup()` is not a retry, it is the host saying "forget it all". + EmbeddedDataManager.shared.removeAll() userManager?.logout() userManager?.cleanupUpdateQueue() presentSurveyManager?.dismissView() diff --git a/Sources/FormbricksSDK/Manager/EmbeddedDataManager.swift b/Sources/FormbricksSDK/Manager/EmbeddedDataManager.swift new file mode 100644 index 0000000..c4c8ef1 --- /dev/null +++ b/Sources/FormbricksSDK/Manager/EmbeddedDataManager.swift @@ -0,0 +1,92 @@ +import Foundation + +/// The in-memory Embedded Data bag: context a host app attaches to future responses without tying +/// it to a trigger — `Formbricks.setEmbeddedData(["screen": "checkout"])` once, instead of +/// repeating the same values on every possible `track(...)` call. +/// +/// Mirrors the JS SDK's store key for key, so web and mobile behave identically. +/// +/// Lifetime rules, all deliberate: +/// +/// - **In-memory, process scoped, never persisted.** Not `UserDefaults`: persisting this bag would +/// blur the Embedded Data ↔ contact-attribute boundary and create a stale-data / PII-at-rest +/// surface. A cold app start begins empty; the host re-pushes. +/// - **Snapshot at display, then frozen.** The WebView payload copies the bag when the survey is +/// shown, so a later `setEmbeddedData` affects the next response, never the one on screen. +/// - **No filtering here.** The SDK is a dumb pipe: the survey renderer applies the ingest contract +/// — allow-list, coercion, `locked`, size caps — and logs what it refuses, and the server re-runs +/// all of it on ingest. Filtering here would ship a second copy of those rules for the four mobile +/// SDKs to drift from. +/// - **Independent of `setup`.** A singleton rather than a manager hung off `Formbricks`, because a +/// host legitimately pushes context before the SDK finishes initializing, and silently dropping +/// that write is the failure this API exists to avoid. `Formbricks.cleanup()` clears it, since +/// that is an explicit full reset of the SDK. +/// - **No network.** Every method is a synchronous memory write, so calling it on every screen +/// change is free. Values ride the existing response payload. +final class EmbeddedDataManager { + static let shared = EmbeddedDataManager() + + /// Same idiom as `UpdateQueue`: the host may call from any thread, and the snapshot is read on + /// the main queue while a survey is being presented. + private let syncQueue = DispatchQueue(label: "com.formbricks.embeddedData") + private var data: [String: EmbeddedDataValue] = [:] + + private init() { + /* + Private so the bag cannot be instantiated a second time: a host holding its own copy would + write into a store the survey payload never reads. + */ + } + + /// Merge — never replace — so refreshing a volatile field (`screen`) cannot wipe the stable ones + /// (`plan`) set at launch. Per key: last write wins, and an explicit `nil` removes the key. + /// + /// A key the caller simply leaves out is untouched; that is how a host skips a field it has no + /// value for this screen. `nil` is the deliberate "remove this" spelling, matching the JS SDK's + /// `{ key: null }`. + func set(_ data: [String: EmbeddedDataValue?]) { + syncQueue.sync { + for (key, value) in data { + guard let value = value else { + self.data.removeValue(forKey: key) + continue + } + // Refused rather than stored: `JSONSerialization` throws on a non-finite Double, and + // the payload it would refuse is the whole survey's props blob — one bad value would + // cost the survey, not the field. Never fatal, always logged. + if case .number(let number) = value, !number.isFinite { + Formbricks.logger?.error( + "setEmbeddedData: \"\(key)\" is not a finite number — the key was skipped") + continue + } + self.data[key] = value + } + } + } + + /// Removes one key. A key that is not set is a no-op. + func remove(key: String) { + syncQueue.sync { + _ = data.removeValue(forKey: key) + } + } + + /// Removes everything — logout, or a hard context switch. + func removeAll() { + syncQueue.sync { + data.removeAll() + } + } + + /// A detached, JSON-safe copy for the display-time snapshot: mutating the bag after a survey has + /// rendered must not reach that survey's response. + func snapshot() -> [String: Any] { + syncQueue.sync { + data.reduce(into: [String: Any]()) { result, entry in + if let value = entry.value.jsonValue { + result[entry.key] = value + } + } + } + } +} diff --git a/Sources/FormbricksSDK/Model/EmbeddedData/EmbeddedDataValue.swift b/Sources/FormbricksSDK/Model/EmbeddedData/EmbeddedDataValue.swift new file mode 100644 index 0000000..3394897 --- /dev/null +++ b/Sources/FormbricksSDK/Model/EmbeddedData/EmbeddedDataValue.swift @@ -0,0 +1,72 @@ +import Foundation + +/// A value a host app may attach to future responses with ``Formbricks/setEmbeddedData(_:)``. +/// +/// Confined to the four scalars the Embedded Data ingest contract can store. A closed enum rather +/// than `Any` on purpose: the bag is serialized into the survey WebView's payload, so an +/// unrepresentable value would not be a dropped field but a `JSONSerialization` failure that takes +/// the whole survey down with it. +/// +/// Supports literal syntax in dictionaries, so the common call reads as plain data: +/// +/// ```swift +/// Formbricks.setEmbeddedData([ +/// "plan": "pro", +/// "seats": 25, +/// "isTrial": false, +/// "screen": nil, // removes the key +/// ]) +/// ``` +/// +/// Dates serialize as ISO 8601, which is what the ingest contract accepts for a `date` field. +public enum EmbeddedDataValue: Equatable { + case string(String) + case number(Double) + case bool(Bool) + case date(Date) + + /// A `JSONSerialization`-safe representation, or `nil` for a value that cannot be serialized. + /// + /// The `nil` case is not defensive habit. `JSONSerialization` **throws** on a non-finite + /// `Double`, and the payload it would have refused is the whole survey's props blob — so a + /// single `.number(.nan)` would mean no survey rather than one missing field. The store rejects + /// those at the door and logs; this is the second line of the same rule. + var jsonValue: Any? { + switch self { + case .string(let value): + return value + case .number(let value): + return value.isFinite ? value : nil + case .bool(let value): + return value + case .date(let value): + return ISO8601DateFormatter().string(from: value) + } + } +} + +// MARK: - Literal conformances for ergonomic dictionary syntax + +extension EmbeddedDataValue: ExpressibleByStringLiteral { + public init(stringLiteral value: String) { + self = .string(value) + } +} + +extension EmbeddedDataValue: ExpressibleByIntegerLiteral { + public init(integerLiteral value: Int) { + self = .number(Double(value)) + } +} + +extension EmbeddedDataValue: ExpressibleByFloatLiteral { + public init(floatLiteral value: Double) { + self = .number(value) + } +} + +extension EmbeddedDataValue: ExpressibleByBooleanLiteral { + public init(booleanLiteral value: Bool) { + self = .bool(value) + } +} diff --git a/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift b/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift index 7c86728..d01e646 100644 --- a/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift +++ b/Sources/FormbricksSDK/WebView/FormbricksViewModel.swift @@ -117,6 +117,12 @@ private class WebViewData { data["environmentId"] = Formbricks.workspaceId data["contactId"] = Formbricks.userManager?.contactId data["isWebEnvironment"] = false + // The Embedded Data bag, snapshotted here — this initializer runs when the survey is + // actually presented, after any configured delay — and frozen for the survey's life. Passed + // raw and unfiltered: the ingest contract (allow-list, coercion, `locked`, size caps) lives + // in the renderer, so all four mobile SDKs inherit the same rules without each shipping a + // copy, and the server re-runs all of it on ingest. + data["hiddenFieldsRecord"] = EmbeddedDataManager.shared.snapshot() data["isBrandingEnabled"] = settings.inAppSurveyBranding ?? true if let placementEnum = matchedSurvey?.projectOverwrites?.placement { diff --git a/Tests/FormbricksSDKTests/EmbeddedDataTests.swift b/Tests/FormbricksSDKTests/EmbeddedDataTests.swift new file mode 100644 index 0000000..f5fc932 --- /dev/null +++ b/Tests/FormbricksSDKTests/EmbeddedDataTests.swift @@ -0,0 +1,258 @@ +import XCTest +@testable import FormbricksSDK + +/// The Embedded Data bag (ENG-1844 / ENG-2472): host-supplied context attached to future responses +/// without tying it to a trigger. These pin the contract the four SDKs share, so a divergence here +/// is a divergence from the JS SDK too. +final class EmbeddedDataTests: XCTestCase { + + override func setUp() { + super.setUp() + Formbricks.cleanup() + EmbeddedDataManager.shared.removeAll() + } + + override func tearDown() { + EmbeddedDataManager.shared.removeAll() + Formbricks.cleanup() + super.tearDown() + } + + /// Initializes the SDK against the mock service, so identity changes can be exercised without + /// the network. + private func setUpSdk() { + Formbricks.setup( + with: FormbricksConfig.Builder(appUrl: "https://app.formbricks.com", workspaceId: "ws-1") + .setLogLevel(.debug) + .service(MockFormbricksService()) + .build()) + } + + /// `snapshot()` returns `[String: Any]`, so comparisons go through `NSDictionary`, which + /// compares element-wise with the bridged equality each value type already has. + private func assertSnapshot(_ expected: [String: Any], file: StaticString = #filePath, line: UInt = #line) { + XCTAssertEqual( + EmbeddedDataManager.shared.snapshot() as NSDictionary, + expected as NSDictionary, + file: file, + line: line + ) + } + + // MARK: - Merge semantics + + func testMergesInsteadOfReplacing() { + Formbricks.setEmbeddedData(["plan": "pro", "screen": "product"]) + Formbricks.setEmbeddedData(["screen": "checkout"]) + + assertSnapshot(["plan": "pro", "screen": "checkout"]) + } + + func testNilRemovesTheKey() { + Formbricks.setEmbeddedData(["plan": "pro", "screen": "product"]) + Formbricks.setEmbeddedData(["screen": nil]) + + assertSnapshot(["plan": "pro"]) + } + + func testLastWriteWinsPerKey() { + Formbricks.setEmbeddedData(["plan": "free"]) + Formbricks.setEmbeddedData(["plan": "pro"]) + + assertSnapshot(["plan": "pro"]) + } + + func testOmittedKeysAreUntouched() { + // Swift has no `undefined`, so "skip this field" is spelled by leaving the key out — and + // that must not disturb what an earlier call set. `nil` is the explicit "remove" spelling. + Formbricks.setEmbeddedData(["plan": "pro", "screen": "product"]) + Formbricks.setEmbeddedData(["seats": 4]) + + assertSnapshot(["plan": "pro", "screen": "product", "seats": 4.0]) + } + + // MARK: - Clearing + + func testClearOneKeyLeavesTheRest() { + Formbricks.setEmbeddedData(["plan": "pro", "screen": "product", "seats": 4]) + + Formbricks.clearEmbeddedData("screen") + + assertSnapshot(["plan": "pro", "seats": 4.0]) + } + + func testClearingAnUnsetKeyIsANoOp() { + Formbricks.setEmbeddedData(["plan": "pro"]) + + Formbricks.clearEmbeddedData("neverSet") + + assertSnapshot(["plan": "pro"]) + } + + func testClearEverything() { + Formbricks.setEmbeddedData(["plan": "pro", "screen": "product"]) + + Formbricks.clearEmbeddedData() + + assertSnapshot([:]) + } + + // MARK: - Value types + + func testEveryScalarSurvivesInItsJsonForm() { + let signedUpAt = Date(timeIntervalSince1970: 1_787_000_000) + + Formbricks.setEmbeddedData([ + "plan": "pro", + "seats": 25, + "score": 9.5, + "isTrial": false, + "signedUpAt": .date(signedUpAt), + ]) + + let snapshot = EmbeddedDataManager.shared.snapshot() + XCTAssertEqual(snapshot["plan"] as? String, "pro") + XCTAssertEqual(snapshot["seats"] as? Double, 25) + XCTAssertEqual(snapshot["score"] as? Double, 9.5) + XCTAssertEqual(snapshot["isTrial"] as? Bool, false) + // ISO 8601 is what the renderer's ingest contract accepts for a `date` field. + XCTAssertEqual(snapshot["signedUpAt"] as? String, ISO8601DateFormatter().string(from: signedUpAt)) + } + + func testASnapshotIsSerializableAsJson() { + // The snapshot is embedded in the survey WebView's props blob, which goes through + // JSONSerialization. If it ever threw, the failure would not be a missing field — it would + // be no survey at all. + Formbricks.setEmbeddedData([ + "plan": "pro", + "seats": 25, + "isTrial": true, + "signedUpAt": .date(Date()), + ]) + + XCTAssertTrue(JSONSerialization.isValidJSONObject(EmbeddedDataManager.shared.snapshot())) + XCTAssertNoThrow( + try JSONSerialization.data(withJSONObject: EmbeddedDataManager.shared.snapshot(), options: [])) + } + + func testANonFiniteNumberIsSkippedRatherThanCostingTheSurvey() { + // THE guard: JSONSerialization throws on a non-finite Double, and the payload it would + // refuse is the whole survey's props blob. Dropping the key is the only safe answer. + Formbricks.setEmbeddedData(["plan": "pro"]) + + Formbricks.setEmbeddedData([ + "broken": .number(Double.nan), + "alsoBroken": .number(Double.infinity), + ]) + + assertSnapshot(["plan": "pro"]) + XCTAssertTrue(JSONSerialization.isValidJSONObject(EmbeddedDataManager.shared.snapshot())) + } + + // MARK: - Lifetime + + func testSnapshotIsDetachedFromLaterWrites() { + // What "a value set after a survey is displayed does not change that response" rests on: + // the WebView payload holds this dictionary for the life of the survey. + Formbricks.setEmbeddedData(["plan": "pro"]) + let snapshot = EmbeddedDataManager.shared.snapshot() + + Formbricks.setEmbeddedData(["plan": "enterprise", "extra": "later"]) + + XCTAssertEqual(snapshot as NSDictionary, ["plan": "pro"] as NSDictionary) + } + + func testWorksBeforeSetup() { + // Deliberately unlike the other public methods: a host that pushes context at launch must + // not have the value dropped because initialization had not finished yet. + XCTAssertFalse(Formbricks.isInitialized) + + Formbricks.setEmbeddedData(["plan": "pro"]) + + assertSnapshot(["plan": "pro"]) + } + + func testIsNotPersisted() { + // A cold start begins empty. Nothing host-supplied may reach UserDefaults, where it would + // outlive the session and blur the Embedded Data ↔ contact-attribute boundary. A UUID + // marker so the search cannot collide with unrelated defaults. + let marker = "fb-embedded-probe-\(UUID().uuidString)" + let keysBefore = Set(UserDefaults.standard.dictionaryRepresentation().keys) + + Formbricks.setEmbeddedData(["probe": .string(marker)]) + + let defaults = UserDefaults.standard.dictionaryRepresentation() + XCTAssertEqual(Set(defaults.keys), keysBefore, "setEmbeddedData wrote a new UserDefaults key") + XCTAssertFalse(defaults.values.contains { String(describing: $0).contains(marker) }) + } + + func testCleanupClearsTheBag() { + Formbricks.setEmbeddedData(["plan": "pro"]) + + Formbricks.cleanup() + + assertSnapshot([:]) + } + + // MARK: - Identity changes + + func testSwitchingUserClearsTheBag() { + setUpSdk() + Formbricks.setUserId("user-a") + Formbricks.setEmbeddedData(["plan": "pro"]) + + Formbricks.setUserId("user-b") + + assertSnapshot([:]) + } + + func testFirstIdentificationKeepsTheBag() { + // The host pushes context before it knows who the user is — that is the normal order, and + // clearing here would throw away the value the API exists to carry. + setUpSdk() + Formbricks.setEmbeddedData(["plan": "pro"]) + + Formbricks.setUserId("user-a") + + assertSnapshot(["plan": "pro"]) + } + + func testSettingTheSameUserIdKeepsTheBag() { + setUpSdk() + Formbricks.setUserId("user-a") + Formbricks.setEmbeddedData(["plan": "pro"]) + + Formbricks.setUserId("user-a") + + assertSnapshot(["plan": "pro"]) + } + + func testLogoutClearsTheBag() { + setUpSdk() + Formbricks.setUserId("user-a") + Formbricks.setEmbeddedData(["plan": "pro"]) + + Formbricks.logout() + + assertSnapshot([:]) + } + + // MARK: - Thread safety + + func testConcurrentWritesDoNotCrash() { + // The host may call from any thread while the main queue reads the snapshot to present a + // survey. Without the serial queue this trips the dictionary's exclusivity checks. + let iterations = 200 + let done = expectation(description: "concurrent writes") + done.expectedFulfillmentCount = iterations + + DispatchQueue.concurrentPerform(iterations: iterations) { index in + Formbricks.setEmbeddedData(["key\(index % 8)": .number(Double(index))]) + _ = EmbeddedDataManager.shared.snapshot() + done.fulfill() + } + + wait(for: [done], timeout: 10) + XCTAssertFalse(EmbeddedDataManager.shared.snapshot().isEmpty) + } +} From 5abfa8a73c324cb865c0955c840bebf7b3cc017e Mon Sep 17 00:00:00 2001 From: Javi Aguilar <122741+itsjavi@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:05:23 +0000 Subject: [PATCH 2/4] test: wait for identity to land before asserting the switch clears the bag [ENG-2472] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI caught what no local check could: `UserManager.set(userId:)` only enqueues into the debounced UpdateQueue, so `userManager?.userId` is still nil immediately after `Formbricks.setUserId`. The switch test therefore took the first-identification branch, where the bag is kept on purpose — asserting an empty bag against a code path that never ran. The production code is right and stays as it is: the clearing sits inside the SDK's own "a different userId is set" branch, so it is exactly as timely as the `userManager?.logout()` teardown beside it. The tests were asserting a state the SDK cannot reach that fast. Identity now settles through the real path — the same 2s wait the SDK's own identity tests use — so the switch and same-id cases exercise the branches they name. First identification logs out first, since `userId` is persisted in UserDefaults and an id left by an earlier test would silently make that case a switch. Also drops the key-set half of the not-persisted probe: an earlier test's in-flight sync can write UserDefaults between the two reads, which would fail for a reason the test is not about. The UUID marker assertion is the actual claim and stays. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018L6PrECN38Jbe1FEdFccTz --- .../EmbeddedDataTests.swift | 30 +++++++++++++++---- 1 file changed, 25 insertions(+), 5 deletions(-) diff --git a/Tests/FormbricksSDKTests/EmbeddedDataTests.swift b/Tests/FormbricksSDKTests/EmbeddedDataTests.swift index f5fc932..14c4ea8 100644 --- a/Tests/FormbricksSDKTests/EmbeddedDataTests.swift +++ b/Tests/FormbricksSDKTests/EmbeddedDataTests.swift @@ -28,6 +28,23 @@ final class EmbeddedDataTests: XCTestCase { .build()) } + /// Identifies as `userId` and waits for the id to actually land. + /// + /// `Formbricks.setUserId` reads `userManager?.userId` to decide whether this is a switch, and + /// that property is only written once the debounced `UpdateQueue` sync completes — `set(userId:)` + /// merely enqueues. Asserting the switch behaviour right after a bare `setUserId` would take the + /// first-identification branch instead and pass for the wrong reason. Same 2s settle the SDK's + /// own identity tests use. + private func identify( + _ userId: String, file: StaticString = #filePath, line: UInt = #line + ) { + Formbricks.setUserId(userId) + let settled = expectation(description: "userId \(userId) settled") + DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { settled.fulfill() } + wait(for: [settled], timeout: 3.0) + XCTAssertEqual(Formbricks.userManager?.userId, userId, file: file, line: line) + } + /// `snapshot()` returns `[String: Any]`, so comparisons go through `NSDictionary`, which /// compares element-wise with the bridged equality each value type already has. private func assertSnapshot(_ expected: [String: Any], file: StaticString = #filePath, line: UInt = #line) { @@ -177,12 +194,12 @@ final class EmbeddedDataTests: XCTestCase { // outlive the session and blur the Embedded Data ↔ contact-attribute boundary. A UUID // marker so the search cannot collide with unrelated defaults. let marker = "fb-embedded-probe-\(UUID().uuidString)" - let keysBefore = Set(UserDefaults.standard.dictionaryRepresentation().keys) Formbricks.setEmbeddedData(["probe": .string(marker)]) + // Only the marker is asserted, deliberately: comparing the whole key set would also catch a + // write from some earlier test's in-flight sync and fail for a reason this test is not about. let defaults = UserDefaults.standard.dictionaryRepresentation() - XCTAssertEqual(Set(defaults.keys), keysBefore, "setEmbeddedData wrote a new UserDefaults key") XCTAssertFalse(defaults.values.contains { String(describing: $0).contains(marker) }) } @@ -198,7 +215,7 @@ final class EmbeddedDataTests: XCTestCase { func testSwitchingUserClearsTheBag() { setUpSdk() - Formbricks.setUserId("user-a") + identify("user-a") Formbricks.setEmbeddedData(["plan": "pro"]) Formbricks.setUserId("user-b") @@ -210,6 +227,10 @@ final class EmbeddedDataTests: XCTestCase { // The host pushes context before it knows who the user is — that is the normal order, and // clearing here would throw away the value the API exists to carry. setUpSdk() + // `userId` is persisted in UserDefaults, so an id left by an earlier test would make this + // take the switch branch. Log out first — which also empties the bag, hence the ordering. + Formbricks.logout() + XCTAssertNil(Formbricks.userManager?.userId) Formbricks.setEmbeddedData(["plan": "pro"]) Formbricks.setUserId("user-a") @@ -219,7 +240,7 @@ final class EmbeddedDataTests: XCTestCase { func testSettingTheSameUserIdKeepsTheBag() { setUpSdk() - Formbricks.setUserId("user-a") + identify("user-a") Formbricks.setEmbeddedData(["plan": "pro"]) Formbricks.setUserId("user-a") @@ -229,7 +250,6 @@ final class EmbeddedDataTests: XCTestCase { func testLogoutClearsTheBag() { setUpSdk() - Formbricks.setUserId("user-a") Formbricks.setEmbeddedData(["plan": "pro"]) Formbricks.logout() From 6f52c71442421a5daa36cc15e8e3278a344ab0f1 Mon Sep 17 00:00:00 2001 From: Javi Aguilar <122741+itsjavi@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:18:32 +0000 Subject: [PATCH 3/4] test: seed the persisted identity instead of waiting on the debounced sync [ENG-2472] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The 2s settle was the wrong shape for the problem. Identity lands only when a network sync completes, so a test that waits is testing the UpdateQueue's timing as much as the branch it names, and it leaves a queued commit running into whatever runs next. Seeding the `UserDefaults` key the getter falls back to is exact: no timer, no request, and it models the honest scenario — the app relaunches already identified, then a different user signs in. `setUserId` then genuinely takes the switch branch, and the same-id case genuinely takes the early return. The seeded id is removed on both sides of every test: `cleanup()` only clears it when a UserManager exists to log out, and this class runs first alphabetically, so a leak would reach `FormbricksSDKTests`, which asserts `userManager?.userId` is nil before setup. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018L6PrECN38Jbe1FEdFccTz --- .../EmbeddedDataTests.swift | 46 ++++++++++++------- 1 file changed, 30 insertions(+), 16 deletions(-) diff --git a/Tests/FormbricksSDKTests/EmbeddedDataTests.swift b/Tests/FormbricksSDKTests/EmbeddedDataTests.swift index 14c4ea8..0d15f30 100644 --- a/Tests/FormbricksSDKTests/EmbeddedDataTests.swift +++ b/Tests/FormbricksSDKTests/EmbeddedDataTests.swift @@ -9,15 +9,27 @@ final class EmbeddedDataTests: XCTestCase { override func setUp() { super.setUp() Formbricks.cleanup() + clearPersistedUserId() EmbeddedDataManager.shared.removeAll() } override func tearDown() { + clearPersistedUserId() EmbeddedDataManager.shared.removeAll() Formbricks.cleanup() super.tearDown() } + /// Removes a seeded identity on both sides of every test. + /// + /// `Formbricks.cleanup()` clears it only when a `UserManager` exists to log out, which it does + /// not before the first `setup`. Without this, a seeded id would outlive this class — which + /// runs first alphabetically — and `FormbricksSDKTests` asserts `userManager?.userId` is nil + /// before setup. + private func clearPersistedUserId() { + UserDefaults.standard.removeObject(forKey: "userIdKey") + } + /// Initializes the SDK against the mock service, so identity changes can be exercised without /// the network. private func setUpSdk() { @@ -28,21 +40,21 @@ final class EmbeddedDataTests: XCTestCase { .build()) } - /// Identifies as `userId` and waits for the id to actually land. + /// Seeds a persisted identity, the way a previous app session would have left one. + /// + /// Not `Formbricks.setUserId`: that only enqueues into the debounced `UpdateQueue`, so + /// `userManager?.userId` stays nil until a network sync lands, and a test driving it that way + /// would silently take the first-identification branch and pass for the wrong reason. Writing + /// the same `UserDefaults` key the getter falls back to is exact, needs no timer or request, and + /// models the honest scenario — the app relaunches already identified, then a different user + /// signs in. /// - /// `Formbricks.setUserId` reads `userManager?.userId` to decide whether this is a switch, and - /// that property is only written once the debounced `UpdateQueue` sync completes — `set(userId:)` - /// merely enqueues. Asserting the switch behaviour right after a bare `setUserId` would take the - /// first-identification branch instead and pass for the wrong reason. Same 2s settle the SDK's - /// own identity tests use. - private func identify( - _ userId: String, file: StaticString = #filePath, line: UInt = #line - ) { - Formbricks.setUserId(userId) - let settled = expectation(description: "userId \(userId) settled") - DispatchQueue.main.asyncAfter(deadline: .now() + 2.0) { settled.fulfill() } - wait(for: [settled], timeout: 3.0) - XCTAssertEqual(Formbricks.userManager?.userId, userId, file: file, line: line) + /// Must be called **before** `setUpSdk()`: `UserManager` caches `backingUserId` on first read, + /// so the value has to be in place when the fresh manager is created. The key is + /// `UserManager`'s own private constant, repeated here because that is the storage contract + /// this seeds. + private func seedPersistedUserId(_ userId: String) { + UserDefaults.standard.set(userId, forKey: "userIdKey") } /// `snapshot()` returns `[String: Any]`, so comparisons go through `NSDictionary`, which @@ -214,8 +226,9 @@ final class EmbeddedDataTests: XCTestCase { // MARK: - Identity changes func testSwitchingUserClearsTheBag() { + seedPersistedUserId("user-a") setUpSdk() - identify("user-a") + XCTAssertEqual(Formbricks.userManager?.userId, "user-a") Formbricks.setEmbeddedData(["plan": "pro"]) Formbricks.setUserId("user-b") @@ -239,8 +252,9 @@ final class EmbeddedDataTests: XCTestCase { } func testSettingTheSameUserIdKeepsTheBag() { + seedPersistedUserId("user-a") setUpSdk() - identify("user-a") + XCTAssertEqual(Formbricks.userManager?.userId, "user-a") Formbricks.setEmbeddedData(["plan": "pro"]) Formbricks.setUserId("user-a") From 4e29cbafb45061c7a713514fbf1fc0fe11ac6312 Mon Sep 17 00:00:00 2001 From: Javi Aguilar <122741+itsjavi@users.noreply.github.com> Date: Mon, 31 Aug 2026 11:03:25 +0000 Subject: [PATCH 4/4] feat: trace setEmbeddedData at debug level, document nil semantics [ENG-2472] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two review findings. 1. setEmbeddedData succeeded in silence, and the bag is invisible — memory-only, no getter — so a host got no confirmation until a survey happened to display. Mirrors the js-core debug trace from formbricks/formbricks#9091: keys set and removed, what the bag now holds, and the sentence that pre-empts the next question. Keys only, never values — the documented use of this bag includes hashed identity fields — and the message is built by a static `setTrace` so that property is directly assertable rather than scraped from stdout. Unlike the other three SDKs the lists are sorted: the bag and the caller's argument are both Swift Dictionaries, whose iteration order is unspecified and varies per process, so without sorting the same bag would print differently run to run. Built and logged outside the sync queue, so a log write never holds it. 2. `nil` removes here where JS `undefined` no-ops, and nothing in the docs said so. Swift has no `undefined`, so the mapping is right, but a host porting the cross-platform idiom of passing every field unconditionally (`["plan": user.plan.map(EmbeddedDataValue.string)]`) silently clears `plan` on iOS where the same code on web leaves it standing. Now spelled out on setEmbeddedData with the fix (build from the keys you have, or use clearEmbeddedData). Three tests added, 19 -> 22. No macOS here, so as before: swiftc -parse clean on every changed file; swiftc -typecheck clean on the real EmbeddedDataTests.swift against signature-matched stubs; and a Linux harness linking the real EmbeddedDataValue.swift and EmbeddedDataManager.swift ran the new setTrace plus set/remove/removeAll — 11 assertions, all passing. --- Sources/FormbricksSDK/Formbricks.swift | 8 ++++ .../Manager/EmbeddedDataManager.swift | 38 +++++++++++++++++++ .../EmbeddedDataTests.swift | 33 ++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/Sources/FormbricksSDK/Formbricks.swift b/Sources/FormbricksSDK/Formbricks.swift index 42d8e5d..8212bf2 100644 --- a/Sources/FormbricksSDK/Formbricks.swift +++ b/Sources/FormbricksSDK/Formbricks.swift @@ -291,6 +291,14 @@ import Network Values land only on the survey's declared *ingested* fields; anything else is dropped and logged by the survey renderer, never fatal. + **`nil` removes; there is no "leave this alone" value.** Swift has no `undefined`, so this SDK + maps `nil` onto the JS SDK's `{ key: null }` (remove) and has nothing that spells its + `{ key: undefined }` (no-op). A host porting the cross-platform idiom of passing every field + unconditionally — `["plan": user.plan.map(EmbeddedDataValue.string)]` — therefore *clears* + `plan` here whenever the optional is empty, where the same code on web would leave the previous + value standing. Build the dictionary from the keys you actually have, or use + `clearEmbeddedData(_:)` when you mean to remove one. + Deliberately callable **before** `setup(with:)`, unlike the methods above: a host that pushes context at launch must not have that value silently dropped because initialization had not finished. The bag is pure memory — nothing here needs the SDK to be running. diff --git a/Sources/FormbricksSDK/Manager/EmbeddedDataManager.swift b/Sources/FormbricksSDK/Manager/EmbeddedDataManager.swift index c4c8ef1..1e69096 100644 --- a/Sources/FormbricksSDK/Manager/EmbeddedDataManager.swift +++ b/Sources/FormbricksSDK/Manager/EmbeddedDataManager.swift @@ -45,10 +45,14 @@ final class EmbeddedDataManager { /// value for this screen. `nil` is the deliberate "remove this" spelling, matching the JS SDK's /// `{ key: null }`. func set(_ data: [String: EmbeddedDataValue?]) { + var setKeys: [String] = [] + var removedKeys: [String] = [] + var held: [String] = [] syncQueue.sync { for (key, value) in data { guard let value = value else { self.data.removeValue(forKey: key) + removedKeys.append(key) continue } // Refused rather than stored: `JSONSerialization` throws on a non-finite Double, and @@ -60,22 +64,56 @@ final class EmbeddedDataManager { continue } self.data[key] = value + setKeys.append(key) } + held = Array(self.data.keys) } + // Built and logged outside the queue, so a log write never holds it. + Formbricks.logger?.debug( + EmbeddedDataManager.setTrace(set: setKeys, removed: removedKeys, held: held)) + } + + /// The success trace, because the bag is otherwise invisible: it lives in memory (nothing in + /// `UserDefaults` to inspect) and the API has no getter, so without this line a host wiring up + /// `setEmbeddedData` gets no confirmation until a survey happens to display. Logged at `.debug`, + /// so it is silent at the default log level. + /// + /// Keys only, never values: the documented use of this bag includes hashed identity fields. + /// Separated from the logging call so that property is directly assertable in a test. + /// + /// Every list is sorted, unlike the other three SDKs, which preserve insertion order: the bag + /// and the caller's argument are both Swift `Dictionary`s, whose iteration order is not + /// specified and varies per process. Sorting is the only way this line reads the same twice. + static func setTrace(set setKeys: [String], removed removedKeys: [String], held: [String]) + -> String + { + let removed = + removedKeys.isEmpty ? "" : ", removed [\(removedKeys.sorted().joined(separator: ", "))]" + return "setEmbeddedData: set [\(setKeys.sorted().joined(separator: ", "))]\(removed) — the " + + "bag now holds [\(held.sorted().joined(separator: ", "))]. Keys land on a response " + + "only if the survey declares them as ingested Embedded Data fields." } /// Removes one key. A key that is not set is a no-op. func remove(key: String) { + var held: [String] = [] syncQueue.sync { _ = data.removeValue(forKey: key) + held = Array(data.keys) } + Formbricks.logger?.debug( + "clearEmbeddedData: removed \"\(key)\" — the bag now holds " + + "[\(held.sorted().joined(separator: ", "))]") } /// Removes everything — logout, or a hard context switch. func removeAll() { + var clearedCount = 0 syncQueue.sync { + clearedCount = data.count data.removeAll() } + Formbricks.logger?.debug("clearEmbeddedData: cleared the whole bag (\(clearedCount) keys)") } /// A detached, JSON-safe copy for the display-time snapshot: mutating the bag after a survey has diff --git a/Tests/FormbricksSDKTests/EmbeddedDataTests.swift b/Tests/FormbricksSDKTests/EmbeddedDataTests.swift index 0d15f30..abe1aff 100644 --- a/Tests/FormbricksSDKTests/EmbeddedDataTests.swift +++ b/Tests/FormbricksSDKTests/EmbeddedDataTests.swift @@ -271,6 +271,39 @@ final class EmbeddedDataTests: XCTestCase { assertSnapshot([:]) } + // MARK: - The debug success trace + + func testTheSuccessTraceNamesKeysAndNeverValues() { + // The bag is otherwise invisible — memory-only, no getter — so this trace is a host's only + // confirmation that a write landed. Its one hard rule: the documented use of this bag + // includes hashed identity fields, so a value must never reach a log line. + let message = EmbeddedDataManager.setTrace( + set: ["plan", "hashedEmail"], removed: ["screen"], held: ["hashedEmail", "plan"]) + + XCTAssertTrue(message.contains("set [hashedEmail, plan]")) + XCTAssertTrue(message.contains("removed [screen]")) + XCTAssertTrue(message.contains("the bag now holds [hashedEmail, plan]")) + XCTAssertTrue(message.contains("only if the survey declares them")) + } + + func testTheSuccessTraceOmitsTheRemovedListWhenNothingWasRemoved() { + let message = EmbeddedDataManager.setTrace(set: ["plan"], removed: [], held: ["plan"]) + + XCTAssertFalse(message.contains("removed")) + } + + func testTheSuccessTraceIsStableAcrossCalls() { + // Swift dictionaries iterate in an unspecified order that varies per process, so the trace + // sorts every list. Without that, the same bag would print differently run to run. + let first = EmbeddedDataManager.setTrace( + set: ["b", "a", "c"], removed: [], held: ["c", "a", "b"]) + let second = EmbeddedDataManager.setTrace( + set: ["c", "b", "a"], removed: [], held: ["a", "b", "c"]) + + XCTAssertEqual(first, second) + XCTAssertTrue(first.contains("set [a, b, c]")) + } + // MARK: - Thread safety func testConcurrentWritesDoNotCrash() {