Skip to content

feat: setEmbeddedData() + clearEmbeddedData() for app surveys [ENG-2472] - #44

Merged
pandeymangg merged 2 commits into
mainfrom
claude/mobile-sdk-embedded-data-4dwd3r
Aug 31, 2026
Merged

feat: setEmbeddedData() + clearEmbeddedData() for app surveys [ENG-2472]#44
pandeymangg merged 2 commits into
mainfrom
claude/mobile-sdk-embedded-data-4dwd3r

Conversation

@itsjavi

@itsjavi itsjavi commented Aug 28, 2026

Copy link
Copy Markdown
Member

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.

Formbricks.setEmbeddedData({'screen': 'checkout', 'plan': 'pro'});
Formbricks.setEmbeddedData({'screen': null});  // remove one key
Formbricks.clearEmbeddedData('plan');          // same, explicitly
Formbricks.clearEmbeddedData();                // everything — logout, context switch

Mirrors js-core's setEmbeddedData key for key (formbricks/formbricks#8989), so web and mobile behave identically.

  • Merge, never replace. null removes a key; a key left out is untouched — that is how a host skips a field it has no value for this screen.
  • clearEmbeddedData takes Object? around a private sentinel, not a plain String?. Dart has neither overloads nor arity, and "no argument" must stay different from "a key that evaluated to null" — the distinction the JS SDK draws by argument count. clearEmbeddedData(prefs['fieldToClear']) with that state empty is a logged no-op, not a wipe. The sentinel is a const instance of a library-private class, not a const Object(): const instances are canonicalized, so the latter is identical to every const Object() in the program and would let host code wipe the bag by accident.
  • In-memory, never persisted. Not SharedPreferences — persisting would blur the Embedded Data ↔ contact-attribute boundary and create a PII-at-rest surface. Cleared on an identity switch and on logout; kept on first identification, because a host legitimately pushes context before it knows who the user is.
  • Synchronous, outside the command queue, unlike every other public method: a host that pushes context at launch must not have the value dropped because setup had not finished.
  • Snapshot at display, then frozen. Read in _present(), which runs after any configured delay.
  • Dumb pipe. The bag rides the render options under 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.
  • A debug-level success trace, because the bag is otherwise invisible — memory-only, no getter — so a host wiring this up got no confirmation until a survey happened to display. Mirrors js-core (feat(js-core): debug-log the Embedded Data bag's successful writes [ENG-1844] formbricks#9091). Keys only, never values: the documented use of this bag includes hashed identity fields.

Where to look: lib/src/survey/embedded_data.dart (the store, every lifetime rule, and the trace) · the sentinel in lib/src/widgets/formbricks_widget.dart · lib/src/user/user.dart (identity-switch clearing) · the two new pipe tests in test/widgets/survey_webview_test.dart.

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

Run against Flutter 3.44.9 / Dart 3.12.2 (the repo needs Dart ^3.12.0).

  • flutter test339 passed, up from 313 on main · make analyze-ci (--fatal-infos --fatal-warnings) ✅ No issues found · make format-check ✅ clean (dart format + prettier on markdown)
  • test/survey/embedded_data_test.dart (26) covers merge, null removal, omitted-key no-op, single-key clear, clear-all, clearEmbeddedData(null) as a no-op rather than a wipe, the two sentinel-forgery cases, a non-String key, every scalar with DateTime→ISO 8601, local DateTime normalized to UTC, JSON-encodability, the non-finite-number guard, unsupported value types, the detached snapshot, works-before-setup, and the five debug-trace cases.
  • test/widgets/survey_webview_test.dart gains two widget tests driving _present() — see the fold.
  • test/user/user_test.dart gains four: switch clears · first identification keeps · same id keeps · logout clears. Mutation-checked — commenting out both EmbeddedDataStore.instance.clear() calls in lib/src/user/user.dart turns exactly "switching to a different userId clears the bag" and "logout clears the bag" red, leaving the two "keeps" cases green.
  • test/widgets/survey_html_test.dart gains two pinning that hiddenFieldsRecord reaches the render options — including a key the survey does not declare (the SDK must not filter) — and that it is {} when the host set nothing.
Why a non-finite number is refused at the door

jsonEncode throws on NaN/Infinity, and the payload it would refuse is the whole survey's render options — buildSurveyHtml would produce nothing and no survey would appear. A single setEmbeddedData({'x': double.nan}) from host code would therefore cost the survey rather than the field. The store drops it with a log instead, and a test asserts the snapshot stays jsonEncode-able. Same reasoning refuses List/Map values, which the ingest contract cannot store either.

Review follow-ups (commit 6565b2b)

Four findings, three of them defects in this diff:

1. The clear-everything sentinel was forgeable. Dart canonicalizes const instances, so the const Object() marker was identical to every const Object() in the program — and its own doc comment claimed the opposite. Verified before fixing:

OLD  no-arg              -> true
OLD  const Object() arg  -> true   <-- forged, wipes the bag
NEW  no-arg              -> true
NEW  const Object() arg  -> false

A static final _Sentinel() cannot be used, because a default parameter value must be a compile-time constant. It is now a const instance of a library-private _ClearWholeBag: canonicalization only unifies two const _ClearWholeBag() expressions, and host code cannot name the type to write one. Reverting turns exactly the forgery test red.

2. The sentinel's dartdoc had stolen the class doc. It sat between the Formbricks class's /// block and the declaration with no blank line, and Dart attaches a contiguous run of doc comments to the next declaration — so the class shipped undocumented. The sentinel now lives above the class doc entirely.

3. The pipe's load-bearing line had no test. _present() passing hiddenFieldsRecord: EmbeddedDataStore.instance.snapshot() is the single line joining the store, the render options and the renderer; the store, the options and the identity-clear were each tested in isolation, so deleting it left every test green while the survey still rendered — a silent failure. Two widget tests now drive _present(): one immediate, and one on a delayed survey where the bag is empty at mount and filled while the timer runs, which pins "read at display, not at mount" through the real widget. Deleting the line now turns exactly those two red.

4. The debug success trace, mirroring formbricks/formbricks#9091. Logger routes through debugPrint, which flutter_test lets a test swap, so no new seam was needed — the tests assert the keys, the omitted-removed case, that no value ever appears, and that the trace is silent at the default error level.

Breaking changes

None. Two new static methods and one new optional field on SurveyHtmlOptions (defaulted, so existing construction is unaffected). setup, track, setUserId, setAttribute(s), setLanguage and logout keep their signatures; logout() and an identity-switching setUserId() additionally clear the new in-memory bag, which did not exist before.

QA / Test Plan

How to test

  • Declare an Embedded Data / hidden field plan on an app survey. Call Formbricks.setEmbeddedData({'plan': 'pro'}), then Formbricks.track() the survey's action → the response shows plan = pro.
  • setEmbeddedData({'plan': 'pro'}) then setEmbeddedData({'screen': 'checkout'}) → the response carries both. Merge, not replace.
  • setEmbeddedData({'plan': null})plan is absent from the next response, screen is still there.
  • clearEmbeddedData() with no argument → the next response carries none of the fields.
  • clearEmbeddedData(null)nothing is cleared, and a line is logged. This is the one that is easy to get backwards.
  • clearEmbeddedData(const Object())nothing is cleared. Before this PR's follow-up commit that call wiped the bag.
  • With logLevel: LogLevel.debug, watch the console while calling setEmbeddedData / clearEmbeddedData → each call logs the keys it set or removed and what the bag now holds. No values appear in any line. At the default level, nothing is logged.
  • Open a survey, call setEmbeddedData({'plan': 'enterprise'}) while it is on screen, finish it → the response records the value from when the survey appeared.
  • Same with a survey that has a delay: set the value during the delay → the response carries the value as of when it actually appeared.
  • Send a key the survey does not declare → the response is created normally without it, and the WebView console logs that the key was dropped. Nothing throws.
  • setUserId('a')setEmbeddedData({'plan': 'pro'})setUserId('b') → survey → the response carries no plan.
  • logout() after setting values → the next response carries none of them.
  • Kill and relaunch the app without re-pushing → the next response carries nothing. The bag is memory-only by design.
  • setEmbeddedData({'items': ['a','b']}) or {'x': double.nan} from host code → logged and skipped, the survey still renders.

Preconditions / test data

  • An app survey with at least one ingested field (a hidden field works), the SDK pointed at an instance serving the current surveys.umd.cjs, and apps/playground or a host app. Response card is the readout.

Risks & regressions

  • logout() and identity-switching setUserId() now also clear the bag. Nothing else observes it, so no other behaviour changes.
  • The render options JSON grows one key. scriptSafeJson still neutralizes <, > and the JS line separators over the whole blob, so a host-supplied value cannot break out of the inline script — worth re-checking with a value containing </script>.
  • The new trace prints field names at debug level. Intended and matching js-core, but it is the one new thing reaching a console — the no-values rule is what keeps it safe, and it is asserted rather than assumed.

Migrations / env / cutover

  • none. Ships as a normal pub release; no server or config change.

Generated by Claude Code

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': null});  // remove one key
    Formbricks.clearEmbeddedData('plan');          // same, explicitly
    Formbricks.clearEmbeddedData();                // everything

Merge, never replace, so refreshing a volatile field cannot wipe a stable
one. `null` removes a key; a key left out is untouched, which is how a host
skips a field it has no value for this screen.

`clearEmbeddedData` takes `Object?` around a private sentinel rather than a
plain `String?`, so "called with no argument" and "called with a key that
evaluated to null" stay different things — the distinction the JS SDK draws
by argument count. `clearEmbeddedData(prefs['fieldToClear'])` with that
state empty must not wipe the bag; it is a logged no-op.

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 and on logout 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.

Synchronous and outside the command queue, unlike every other public method:
a host that pushes context at launch must not have the value dropped because
setup had not finished.

Snapshotted in `_present()`, which runs when the survey is actually shown
after any configured delay, and frozen for its lifetime. The bag rides the
render options that already exist, 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 value of an unsupported type, and a non-finite number, are logged and
skipped: `jsonEncode` throws on NaN, and the payload it would refuse is the
whole survey's render options, so one 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
@coderabbitai

coderabbitai Bot commented Aug 28, 2026

Copy link
Copy Markdown
Contributor

Important

  • 🔍 Trigger review

This repository does not receive automatic reviews because it has fewer than 10 stars.

⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 0357f9fb-4457-4558-a624-f5107787bb73


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.

@itsjavi
itsjavi requested a review from pandeymangg August 28, 2026 15:12
@pandeymangg

Copy link
Copy Markdown
Contributor

Four things from manual testing + review (tested locally — the pipe works end-to-end 👍):

  1. The pipe's load-bearing line has no test. survey_webview.dart's _present() passing hiddenFieldsRecord: EmbeddedDataStore.instance.snapshot() is the one line joining the store, the options and the renderer — and deleting it leaves all 22 new tests green (store, options-rendering and identity-clear are each tested in isolation). Since this SDK had no hiddenFields plumbing before this PR, that line IS the feature, and its failure mode is silent (survey renders, values just never arrive). One widget test driving _present() and asserting the snapshot reaches the render options closes it.

  2. The clear-all sentinel is forgeable. Dart canonicalizes const objects, so every const Object() in the program is identical to the internal sentinel — Formbricks.clearEmbeddedData(const Object()) wipes the whole bag instead of being refused. A non-const private class instance (static final _clearWholeBag = _Sentinel();) can't be forged.

  3. The sentinel's dartdoc stole the Formbricks class doc — it was inserted between the class's /// block and the declaration without a blank line, and Dart attaches contiguous doc comments to the next declaration.

  4. Debug success tracesetEmbeddedData succeeds silently and the bag is invisible; js-core just added a debug-level trace in feat(js-core): debug-log the Embedded Data bag's successful writes [ENG-1844] formbricks#9091 (keys set/removed + bag contents — keys only, never values). Logger here has LogLevel.debug, so it's a small mirror.

…ENG-2472]

Four review findings, all real:

1. The sentinel was forgeable. Dart canonicalizes const instances, so the
   `const Object()` marker was `identical` to every `const Object()` in the
   program and `Formbricks.clearEmbeddedData(const Object())` from host code
   wiped the whole bag instead of being refused — verified before the fix. It
   is now a const instance of a library-private `_ClearWholeBag`: still a valid
   default parameter value (which must be a compile-time constant), but host
   code cannot name the type, so it cannot produce one.

2. The sentinel's dartdoc had stolen the `Formbricks` class doc — it sat
   between the class's `///` block and the declaration with no blank line, and
   Dart attaches a contiguous run of doc comments to the next declaration. The
   sentinel now lives above the class doc entirely.

3. The pipe's load-bearing line had no test. `_present()` passing
   `hiddenFieldsRecord: EmbeddedDataStore.instance.snapshot()` is the single
   line joining the store, the render options and the renderer, and deleting it
   left every other test green while the survey still rendered — a silent
   failure. Two widget tests now drive `_present()`: one immediate, one that
   sets the value during the delay so "read at display, not at mount" is pinned
   through the real widget.

4. setEmbeddedData succeeded in silence. 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.

flutter test 339 passed (was 330) · analyze --fatal-infos --fatal-warnings
clean · format clean. Mutation-checked: deleting the pipe line turns exactly
the two new widget tests red; restoring `const Object()` turns exactly the
forgery test red.
@sonarqubecloud

Copy link
Copy Markdown

itsjavi commented Aug 31, 2026

Copy link
Copy Markdown
Member Author

All four done in 6565b2b. Findings 1–3 are all real; 2 and 3 are outright bugs I introduced, and 1 is a fair hit on the test coverage.

2. The sentinel was forgeable. Confirmed before fixing — and the doc comment on it claimed the opposite ("no caller can produce a value identical to it by accident"), which made it worse than an omission:

OLD  no-arg              -> true
OLD  const Object() arg  -> true   <-- forged, wipes the bag
OLD  Object() arg        -> false
NEW  no-arg              -> true
NEW  const Object() arg  -> false
NEW  Object() arg        -> false

The fix is not quite what you proposed, because a default parameter value must be a compile-time constant — static final _clearWholeBag = _Sentinel() does not compile in that position. It is now a const instance of a library-private class:

class _ClearWholeBag {
  const _ClearWholeBag();
}
const Object _clearWholeBag = _ClearWholeBag();

Const-ness is fine once the type is unforgeable: canonicalization only makes two const _ClearWholeBag() expressions identical, and host code cannot name _ClearWholeBag to write one. Two tests pin it (const Object() and Object()); reverting to const Object() turns exactly the first one red.

3. The dartdoc theft. Confirmed — the Formbricks class doc ran straight into the sentinel's /// block with no blank line, so Dart attached the whole run to _clearWholeBag and the class shipped undocumented. The sentinel now lives above the class doc entirely, which also reads better than a blank line would.

1. The pipe had no test. You're right, and the mutation check confirms it: deleting hiddenFieldsRecord: EmbeddedDataStore.instance.snapshot() from _present() left the whole suite green before, and now turns exactly two tests red. _StubHost already captures the html, so it was cheap:

  • one immediate present, asserting "hiddenFieldsRecord":{"plan":"pro","screen":"checkout"} reaches the html;
  • one on a delayed survey where the bag is empty at mount and filled while the timer runs, which pins "read at display, not at mount" through the real widget rather than through SurveyHtmlOptions in isolation.

4. Debug trace. Mirrors formbricks/formbricks#9091, at LogLevel.debug so it is silent at the default. Keys only, never values — asserted, including that a hashed_email value never appears in the line — plus a test that the trace is silent at the default error level. Logger routes through debugPrint, which flutter_test lets a test swap, so no new seam was needed.

flutter test 339 passed (was 330) · make analyze-ci (--fatal-infos --fatal-warnings) no issues · make format-check clean.


Generated by Claude Code

@pandeymangg
pandeymangg merged commit 147356e into main Aug 31, 2026
10 checks passed
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