feat: setEmbeddedData() + clearEmbeddedData() for app surveys [ENG-2472] - #54
Conversation
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018L6PrECN38Jbe1FEdFccTz
|
Important
This repository does not receive automatic reviews because it has fewer than 10 stars. ⚙️ Run configurationConfiguration used: Organization UI Review profile: ASSERTIVE Plan: Pro Plus Run ID: 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 |
…e bag [ENG-2472] 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018L6PrECN38Jbe1FEdFccTz
… sync [ENG-2472] 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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018L6PrECN38Jbe1FEdFccTz
|
Two things from manual testing + review:
|
…NG-2472] 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.
|
Both done in 1. Debug trace. Mirrors formbricks/formbricks#9091, at One deliberate divergence from the other three SDKs: the lists are sorted here. The bag and the caller's argument are both Swift The message is built by a 2. The
I considered making the mapping match JS instead (an Three tests added, 19 → 22. Verification unchanged in shape since there is still no macOS here: Generated by Claude Code |
|



What & why
Was:
track()takes a name and nothing else, so the only way to get context onto an app-survey response was to declare a field and have the respondent type it. Now: the host app can attach context to future responses without tying it to a trigger.Mirrors js-core's
setEmbeddedDatakey for key (formbricks/formbricks#8989), so web and mobile behave identically.nilremoves a key; a key left out is untouched — that 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-optionalStringparameter means a host reading the key from its own state cannot accidentally wipe the bag.nilremoves; there is no "leave this alone" value. Swift has noundefined, so this SDK mapsnilonto the JS SDK's{ key: null }and has nothing that spells its{ key: undefined }. A host porting the cross-platform idiom of passing every field unconditionally —["plan": user.plan.map(EmbeddedDataValue.string)]— therefore clearsplanhere whenever the optional is empty, where the same code on web leaves the previous value standing. Documented onsetEmbeddedData, since the failure is otherwise silent.UserDefaults— persisting would blur the Embedded Data ↔ contact-attribute boundary and create a PII-at-rest surface. Cleared on an identity switch, onlogout()and oncleanup(); kept on first identification, because a host legitimately pushes context before it knows who the user is.setup(with:), unlike every other public method: a host that pushes context at launch must not have the value dropped because initialization had not finished.WebViewData's initializer, which runs after any configured delay.hiddenFieldsRecord— no new bridge message. It goes out raw: the ingest contract (allow-list, coercion,locked, size caps) lives in the renderer, and the server re-runs all of it.Where to look:
Manager/EmbeddedDataManager.swift(the store, the serial queue, the trace and every lifetime rule) ·Model/EmbeddedData/EmbeddedDataValue.swift(why the value type is closed) ·Formbricks.swift(the two overloads, thenildocs and identity-switch clearing).Requires the renderer change in formbricks/formbricks#9067 for the auto-capture half; this PR is independent of it and needs no server change.
Linear ticket
https://linear.app/formbricks/issue/ENG-2472/mobile-sdk-parity-for-embedded-data-one-batched-release-per-sdk
How this was tested
No macOS here, so
xcodebuild testruns only in CI — its first run found a real defect in the identity tests, described in the fold. Everything else was verified against the Swift 6.1.2 Linux toolchain:swiftc -parse✅ on every changed and added file:EmbeddedDataValue.swift,EmbeddedDataManager.swift,Formbricks.swift,FormbricksViewModel.swift,EmbeddedDataTests.swift.swiftc -typecheck✅ on the two new sources plus the realEmbeddedDataTests.swift, against stubs whose signatures are copied from the SDK (Formbricks,UserManager,Logger,FormbricksConfig.Builder,FormbricksServiceProtocol). This confirms the dictionary-literal inference —["screen": nil],["seats": 25],"signedUpAt": .date(d)in one literal — resolves against[String: EmbeddedDataValue?]. Re-run after every edit.EmbeddedDataValue.swiftandEmbeddedDataManager.swiftand runs 19 assertions on the store plus 11 on the new trace, all passing. See the fold.The Linux behaviour run, and what it does not cover
And for the trace, against the same real sources:
Not covered by either: the four identity-change cases, the
UserDefaultsprobe, and everything downstream ofWebViewData— exactly where CI found the bug below.What CI caught, and the shape of the fix
UserManager.set(userId:)only enqueues into the debouncedUpdateQueue;userManager?.userIdis written when the sync completes. So immediately afterFormbricks.setUserId("user-a")that property is still nil, and the followingsetUserId("user-b")took the first-identification branch — where the bag is deliberately kept.testSwitchingUserClearsTheBagwas asserting an empty bag against a code path that never ran.The first fix waited 2s for the id to land. That works, but it is the wrong shape: it tests the
UpdateQueue's timing as much as the branch it names, and leaves a queued commit running into whatever executes next. The current commit seeds theUserDefaultskey the getter falls back to instead — exact, no timer, no request, and it models the honest scenario: the app relaunches already identified, then a different user signs in. The seeded id is removed on both sides of every test, becausecleanup()only clears it when aUserManagerexists to log out and this class runs first alphabetically — a leak would reachFormbricksSDKTests, which assertsuserManager?.userIdis nil before setup.The production code is unchanged throughout. The clearing sits inside the SDK's own
if let existing = userManager?.userId, !existing.isEmptybranch, so it is exactly as timely as theuserManager?.logout()teardown beside it.Why a non-finite number is refused at the door
JSONSerializationthrows on a non-finiteDouble, and the payload it would refuse is the whole survey's props blob —WebViewData.getJsonString()would return nil,htmlStringwould stay nil, and no survey would render. So a singlesetEmbeddedData(["x": .number(.nan)])from host code would cost the survey, not the field. The store drops it with a log, and both the XCTest case and the Linux run assert the snapshot stays serializable.Review follow-ups (commit
4e29cba)The debug success trace, mirroring feat(js-core): debug-log the Embedded Data bag's successful writes [ENG-1844] formbricks#9091, at
.debugso it is silent at the default log level. One deliberate divergence from the other three SDKs: the lists are sorted. The bag and the caller's argument are both SwiftDictionarys, whose iteration order is unspecified and varies per process, so an unsorted line would print differently run to run for the same bag — useless for a host diffing two launches, and untestable. RN/Android/Flutter all keep insertion order natively, so only this SDK needs it. The message is built by astatic setTraceso "keys only, never values" is assertable directly rather than scraped from stdout; the logger prints asynchronously on the main queue, which is not something to hang a test on. Built and logged outsidesyncQueue, so a log write never holds the lock.The
nildocstring. The failure is silent, cross-platform and only shows up as missing data later, so it is now spelled out onsetEmbeddedDatawith the fix (build the dictionary from the keys you actually have, or useclearEmbeddedData(_:)). Changing the mapping instead — anEmbeddedDataValue.unchangedcase sonilcould no-op — was considered and rejected:["screen": nil]reading as "remove" is the obvious meaning of a nil dictionary value in Swift, and a sentinel case a host must remember to use is a worse trap than the one being documented.Breaking changes
None. Three new methods on
Formbricks, one new publicEmbeddedDataValueenum, and one new key in a payload the renderer already accepts.setup,track,setUserId,setAttribute(s),setLanguageandlogoutkeep their signatures.logout(),cleanup()and an identity-switchingsetUserId()additionally clear the new in-memory bag, which did not exist before.The new methods are Swift-only, not
@objc—EmbeddedDataValuehas associated values, the same reasonsetAttributes(_:)is Swift-only today.QA / Test Plan
How to test
planon an app survey. CallFormbricks.setEmbeddedData(["plan": "pro"]), thenFormbricks.track()the survey's action → the response showsplan = pro.plan, then setscreenin a second call → the response carries both. Merge, not replace.setEmbeddedData(["plan": nil])→planis absent from the next response,screenis still there.["plan": user.plan.map(EmbeddedDataValue.string)]withuser.planempty →planis removed. This is the documented sharp edge and worth seeing once: the identical code on web would leave the value standing.clearEmbeddedData()with no argument → the next response carries none of the fields;clearEmbeddedData("plan")removes only that one..debug(or.verbose) logging, watch the console while callingsetEmbeddedData/clearEmbeddedData→ each call logs the keys it set or removed and what the bag now holds, in a stable order. No values appear in any line. At the default level, nothing is logged.setEmbeddedDatawhile it is on screen, finish it → the response records the value from when the survey appeared.setUserId("a")→ wait for the sync to land → set values →setUserId("b")→ survey → the response carries none of them. Waiting matters: identity is debounced, so a back-to-back switch is not yet a switch to the SDK.logout(), and separatelycleanup(), after setting values → the next response carries none of them.setEmbeddedData(["x": .number(.nan)])from host code → logged and skipped, and the survey still renders. This is the one that would fail loudly if the guard were missing.Datevalue → arrives on the response as an ISO 8601 string and reads back as a date on adate-typed field.Preconditions / test data
surveys.umd.cjs, and a host app. Response card is the readout.Risks & regressions
userManager?.userId, which lands after the debounced/usersync — so asetUserId("a"); setUserId("b")in the same run loop clears neither the user state nor the bag. Pre-existing behaviour, not new here, but it is what to expect when testing.logout(),cleanup()and identity-switchingsetUserId()now also clear the bag. Nothing else observes it, so no other behaviour changes.</script>..debug. Intended and matching js-core; the no-values rule is what keeps it safe, and it is asserted rather than assumed.Migrations / env / cutover
Generated by Claude Code