From 0e5cc0c749da83b04fe725b44aaacc514a5605f3 Mon Sep 17 00:00:00 2001 From: Javi Aguilar <122741+itsjavi@users.noreply.github.com> Date: Fri, 28 Aug 2026 12:58:37 +0000 Subject: [PATCH 1/2] 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': 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 Claude-Session: https://claude.ai/code/session_018L6PrECN38Jbe1FEdFccTz --- .../lib/src/survey/embedded_data.dart | 105 ++++++++++ packages/formbricks/lib/src/user/user.dart | 9 + .../lib/src/widgets/formbricks_widget.dart | 60 ++++++ .../lib/src/widgets/survey_html.dart | 9 + .../lib/src/widgets/survey_webview.dart | 6 + .../test/survey/embedded_data_test.dart | 193 ++++++++++++++++++ packages/formbricks/test/user/user_test.dart | 54 +++++ .../test/widgets/survey_html_test.dart | 30 +++ 8 files changed, 466 insertions(+) create mode 100644 packages/formbricks/lib/src/survey/embedded_data.dart create mode 100644 packages/formbricks/test/survey/embedded_data_test.dart diff --git a/packages/formbricks/lib/src/survey/embedded_data.dart b/packages/formbricks/lib/src/survey/embedded_data.dart new file mode 100644 index 0000000..d7dc333 --- /dev/null +++ b/packages/formbricks/lib/src/survey/embedded_data.dart @@ -0,0 +1,105 @@ +/// 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. +library; + +import '../common/logger.dart'; + +/// Holds the host-supplied Embedded Data for this process. +/// +/// Lifetime rules, all deliberate: +/// +/// * **In-memory, process scoped, never persisted.** Not `SharedPreferences`: +/// 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.** `SurveyWebView` copies the bag into +/// the survey's render options 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 host legitimately pushes context before the +/// SDK finishes initializing, and silently dropping that write is the failure +/// this API exists to avoid. +/// * **No network.** Every method is a synchronous memory write, so calling it +/// on every screen change is free. Values ride the existing response payload. +class EmbeddedDataStore { + EmbeddedDataStore._(); + + /// The process-wide bag. + static final EmbeddedDataStore instance = EmbeddedDataStore._(); + + final Map _data = {}; + + /// Merges [values] into the bag — never replaces it — so refreshing a + /// volatile field (`screen`) cannot wipe the stable ones (`plan`) set at + /// launch. Per key: last write wins, and an explicit `null` 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. `null` is the deliberate "remove + /// this" spelling, matching the JS SDK's `{ key: null }`. + /// + /// Values must be a `String`, `num`, `bool` or `DateTime` — the four scalars + /// the ingest contract can store. Anything else is logged and skipped rather + /// than thrown: a host mistake must never cost a response. A non-finite `num` + /// is refused for the same reason, and a sharper one — `jsonEncode` throws on + /// `NaN`/`Infinity`, and the payload it would refuse is the whole survey's + /// render options, so one bad value would cost the survey, not the field. + void set(Map values) { + for (final entry in values.entries) { + final value = entry.value; + if (value == null) { + _data.remove(entry.key); + continue; + } + if (value is num && !value.isFinite) { + Logger.error( + 'setEmbeddedData: "${entry.key}" is not a finite number — ' + 'the key was skipped', + ); + continue; + } + if (value is! String && + value is! num && + value is! bool && + value is! DateTime) { + Logger.error( + 'setEmbeddedData: "${entry.key}" is a ${value.runtimeType}, which ' + 'cannot be stored — the key was skipped', + ); + continue; + } + _data[entry.key] = value; + } + } + + /// Removes one [key]. A key that is not set is a no-op. + void remove(String key) { + _data.remove(key); + } + + /// Removes everything — logout, or a hard context switch. + void clear() { + _data.clear(); + } + + /// 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. + /// + /// `DateTime` is serialized as ISO 8601, which is what the renderer's ingest + /// contract accepts for a `date` field. Everything else `jsonEncode` already + /// handles. + Map snapshot() => { + for (final entry in _data.entries) + entry.key: entry.value is DateTime + ? (entry.value as DateTime).toUtc().toIso8601String() + : entry.value, + }; +} diff --git a/packages/formbricks/lib/src/user/user.dart b/packages/formbricks/lib/src/user/user.dart index 563b307..903ad33 100644 --- a/packages/formbricks/lib/src/user/user.dart +++ b/packages/formbricks/lib/src/user/user.dart @@ -10,6 +10,7 @@ import '../common/config.dart'; import '../common/logger.dart'; import '../common/result.dart'; import '../common/setup.dart'; +import '../survey/embedded_data.dart'; import '../types/errors.dart'; import 'update_queue.dart'; @@ -45,6 +46,11 @@ Future> setUserId( // the new user. q.clear(); await tearDown(config: cfg); + // 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. + EmbeddedDataStore.instance.clear(); } q.updateUserId(userId); @@ -65,5 +71,8 @@ Future> logout({ // after logout. (queue ?? UpdateQueue.instance).clear(); await tearDown(config: config ?? FormbricksConfig.instance); + // Same identity-switch rule as setUserId: logout must not let the previous + // user's ambient context leak onto whoever uses the app next. + EmbeddedDataStore.instance.clear(); return const Result.ok(null); } diff --git a/packages/formbricks/lib/src/widgets/formbricks_widget.dart b/packages/formbricks/lib/src/widgets/formbricks_widget.dart index 47bd81e..aff980b 100644 --- a/packages/formbricks/lib/src/widgets/formbricks_widget.dart +++ b/packages/formbricks/lib/src/widgets/formbricks_widget.dart @@ -13,6 +13,7 @@ import '../common/logger.dart'; import '../common/result.dart'; import '../common/setup.dart' as setup_internal; import '../survey/action.dart' as action; +import '../survey/embedded_data.dart'; import '../survey/survey_store.dart'; import '../types/errors.dart'; import '../types/survey.dart'; @@ -29,6 +30,10 @@ import 'webview_navigation.dart'; /// modal WebView route. The imperative API (`setup`, `track`) lives as static /// methods that route through a hidden command queue so calls run in strict /// submission order. +/// The "no argument" marker for [Formbricks.clearEmbeddedData]. A private const +/// object, so no caller can produce a value `identical` to it by accident. +const Object _clearWholeBag = Object(); + class Formbricks extends StatefulWidget { /// Creates the host widget for [appUrl] / [workspaceId]. const Formbricks({ @@ -154,6 +159,61 @@ class Formbricks extends StatefulWidget { ); } + /// 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 + /// `null` 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. Values must be a `String`, `num`, `bool` or `DateTime`. + /// + /// Deliberately synchronous and **not** routed through the command queue, + /// unlike the methods above: a host that pushes context at launch must not + /// have that value silently dropped because `setup` had not finished. The bag + /// is pure memory — nothing here needs the SDK to be running, and calling it + /// on every screen change is free. + /// + /// 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. + /// + /// ```dart + /// Formbricks.setEmbeddedData({'plan': 'pro', 'seats': 25}); + /// Formbricks.setEmbeddedData({'screen': null}); // removes the key + /// ``` + static void setEmbeddedData(Map data) { + EmbeddedDataStore.instance.set(data); + } + + /// Removes one Embedded Data key, or the whole bag when called with no + /// argument — logout, or a hard context switch. + /// + /// ```dart + /// Formbricks.clearEmbeddedData('plan'); // one key + /// Formbricks.clearEmbeddedData(); // everything + /// ``` + /// + /// The [key] is typed `Object?` around a private sentinel rather than as a + /// plain `String?`, so that "called with no argument" and "called with a key + /// that evaluated to null" stay different things — the same distinction the + /// JS SDK draws by argument count. A host that reads the key from its own + /// state (`clearEmbeddedData(prefs['fieldToClear'])`) must not wipe the whole + /// bag when that state is empty; that call is a logged no-op. + static void clearEmbeddedData([Object? key = _clearWholeBag]) { + if (identical(key, _clearWholeBag)) { + EmbeddedDataStore.instance.clear(); + return; + } + if (key is! String) { + Logger.error( + 'clearEmbeddedData: expected a field name — nothing was cleared ' + '(call with no argument to clear everything)', + ); + return; + } + EmbeddedDataStore.instance.remove(key); + } + /// Logs the current user out, resetting user state to anonymous /// (`checkSetup: true`). static Future> logout() { diff --git a/packages/formbricks/lib/src/widgets/survey_html.dart b/packages/formbricks/lib/src/widgets/survey_html.dart index 64e491b..0416691 100644 --- a/packages/formbricks/lib/src/widgets/survey_html.dart +++ b/packages/formbricks/lib/src/widgets/survey_html.dart @@ -28,6 +28,7 @@ class SurveyHtmlOptions { this.placement, this.clickOutside, this.overlay, + this.hiddenFieldsRecord = const {}, }); /// The survey to render. @@ -60,6 +61,13 @@ class SurveyHtmlOptions { /// Overlay mode, when set. final String? overlay; + /// The Embedded Data bag, snapshotted when the survey is displayed and frozen + /// for its 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. + final Map hiddenFieldsRecord; + /// The `renderSurvey` options object. Map toRenderOptions() => { 'workspaceId': workspaceId, @@ -73,6 +81,7 @@ class SurveyHtmlOptions { if (clickOutside != null) 'clickOutside': clickOutside, if (overlay != null) 'overlay': overlay, 'isWebEnvironment': false, + 'hiddenFieldsRecord': hiddenFieldsRecord, }; } diff --git a/packages/formbricks/lib/src/widgets/survey_webview.dart b/packages/formbricks/lib/src/widgets/survey_webview.dart index 19eec32..4049bd5 100644 --- a/packages/formbricks/lib/src/widgets/survey_webview.dart +++ b/packages/formbricks/lib/src/widgets/survey_webview.dart @@ -16,6 +16,7 @@ import '../common/config.dart'; import '../common/filter_surveys.dart'; import '../common/logger.dart'; import '../common/utils.dart'; +import '../survey/embedded_data.dart'; import '../survey/survey_store.dart'; import '../types/config.dart'; import '../types/survey.dart'; @@ -164,6 +165,11 @@ class _SurveyWebViewState extends State { clickOutside: overwrites?.clickOutsideClose ?? _asBool(settings['clickOutsideClose']), overlay: overlay, + // Read here and nowhere else: `_present` runs when the survey is + // actually shown, after any configured delay, and the html it builds is + // handed to the WebView once. A value set after this point reaches the + // next response, never the one on screen. + hiddenFieldsRecord: EmbeddedDataStore.instance.snapshot(), ), ); diff --git a/packages/formbricks/test/survey/embedded_data_test.dart b/packages/formbricks/test/survey/embedded_data_test.dart new file mode 100644 index 0000000..deb79f9 --- /dev/null +++ b/packages/formbricks/test/survey/embedded_data_test.dart @@ -0,0 +1,193 @@ +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:formbricks/formbricks.dart'; +import 'package:formbricks/src/common/logger.dart'; +import 'package:formbricks/src/survey/embedded_data.dart'; + +/// 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 all +/// four SDKs share, so a divergence here is a divergence from the JS SDK too. +void main() { + final store = EmbeddedDataStore.instance; + + setUp(() { + Logger.resetInstance(); + store.clear(); + }); + + tearDown(store.clear); + + group('merge semantics', () { + test('merges instead of replacing: setting one key keeps the others', () { + Formbricks.setEmbeddedData({'plan': 'pro', 'screen': 'product'}); + Formbricks.setEmbeddedData({'screen': 'checkout'}); + + expect(store.snapshot(), {'plan': 'pro', 'screen': 'checkout'}); + }); + + test('null drops the key', () { + Formbricks.setEmbeddedData({'plan': 'pro', 'screen': 'product'}); + Formbricks.setEmbeddedData({'screen': null}); + + expect(store.snapshot(), {'plan': 'pro'}); + }); + + test('last write wins per key', () { + Formbricks.setEmbeddedData({'plan': 'free'}); + Formbricks.setEmbeddedData({'plan': 'pro'}); + + expect(store.snapshot(), {'plan': 'pro'}); + }); + + test('omitted keys are untouched', () { + // Dart has no `undefined`, so "skip this field" is spelled by leaving the + // key out — and that must not disturb what an earlier call set. `null` is + // the explicit "remove" spelling. + Formbricks.setEmbeddedData({'plan': 'pro'}); + Formbricks.setEmbeddedData({'seats': 4}); + + expect(store.snapshot(), {'plan': 'pro', 'seats': 4}); + }); + }); + + group('clearing', () { + test('clearEmbeddedData(key) removes one key', () { + Formbricks.setEmbeddedData({'plan': 'pro', 'screen': 'product'}); + + Formbricks.clearEmbeddedData('screen'); + + expect(store.snapshot(), {'plan': 'pro'}); + }); + + test('clearing an unset key is a no-op', () { + Formbricks.setEmbeddedData({'plan': 'pro'}); + + Formbricks.clearEmbeddedData('neverSet'); + + expect(store.snapshot(), {'plan': 'pro'}); + }); + + test('clearEmbeddedData() with no argument removes everything', () { + Formbricks.setEmbeddedData({'plan': 'pro', 'screen': 'product'}); + + Formbricks.clearEmbeddedData(); + + expect(store.snapshot(), isEmpty); + }); + + test('clearEmbeddedData(null) is a no-op, NOT a full clear', () { + // One keystroke from the no-argument form and the opposite behaviour. A + // host reading the key from its own state must not wipe the whole bag + // when that state is empty — the sentinel default is what keeps the two + // apart, the same distinction the JS SDK draws by argument count. + Formbricks.setEmbeddedData({'plan': 'pro', 'screen': 'product'}); + + Formbricks.clearEmbeddedData(null); + + expect(store.snapshot(), {'plan': 'pro', 'screen': 'product'}); + }); + + test('clearEmbeddedData with a non-string key is a no-op', () { + Formbricks.setEmbeddedData({'plan': 'pro'}); + + Formbricks.clearEmbeddedData(42); + + expect(store.snapshot(), {'plan': 'pro'}); + }); + }); + + group('value types', () { + test('every scalar survives, with DateTime as ISO 8601', () { + final signedUpAt = DateTime.utc(2026, 8, 20, 10); + + Formbricks.setEmbeddedData({ + 'plan': 'pro', + 'seats': 25, + 'score': 9.5, + 'isTrial': false, + 'signedUpAt': signedUpAt, + }); + + expect(store.snapshot(), { + 'plan': 'pro', + 'seats': 25, + 'score': 9.5, + 'isTrial': false, + // ISO 8601 is what the renderer's ingest contract accepts for a `date`. + 'signedUpAt': '2026-08-20T10:00:00.000Z', + }); + }); + + test('a local DateTime is normalized to UTC, not left ambiguous', () { + final local = DateTime.utc(2026, 8, 20, 10).toLocal(); + + Formbricks.setEmbeddedData({'signedUpAt': local}); + + expect(store.snapshot()['signedUpAt'], '2026-08-20T10:00:00.000Z'); + }); + + test('a snapshot is always JSON-encodable', () { + // The snapshot is embedded in the survey WebView's render options. If + // jsonEncode 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': DateTime.now(), + }); + + expect(() => jsonEncode(store.snapshot()), returnsNormally); + }); + + test('a non-finite number is skipped rather than costing the survey', () { + // THE guard: jsonEncode throws on NaN/Infinity, and the payload it would + // refuse is the whole survey's render options. + Formbricks.setEmbeddedData({'plan': 'pro'}); + + Formbricks.setEmbeddedData({ + 'broken': double.nan, + 'alsoBroken': double.infinity, + }); + + expect(store.snapshot(), {'plan': 'pro'}); + expect(() => jsonEncode(store.snapshot()), returnsNormally); + }); + + test('an unsupported value type is skipped, never thrown', () { + Formbricks.setEmbeddedData({'plan': 'pro'}); + + expect( + () => Formbricks.setEmbeddedData({ + 'items': ['a', 'b'], + 'nested': {'a': 1}, + }), + returnsNormally, + ); + expect(store.snapshot(), {'plan': 'pro'}); + }); + }); + + group('lifetime', () { + test('snapshot is detached: later writes do not reach an earlier one', () { + // What "a value set after a survey is displayed does not change that + // response" rests on — the render options hold this map for the life of + // the survey. + Formbricks.setEmbeddedData({'plan': 'pro'}); + final snapshot = store.snapshot(); + + Formbricks.setEmbeddedData({'plan': 'enterprise', 'extra': 'later'}); + + expect(snapshot, {'plan': 'pro'}); + }); + + test('works before setup', () { + // Deliberately unlike the queued methods: a host that pushes context at + // launch must not have the value dropped because setup had not finished. + Formbricks.setEmbeddedData({'plan': 'pro'}); + + expect(store.snapshot(), {'plan': 'pro'}); + }); + }); +} diff --git a/packages/formbricks/test/user/user_test.dart b/packages/formbricks/test/user/user_test.dart index 4179ac6..73392e1 100644 --- a/packages/formbricks/test/user/user_test.dart +++ b/packages/formbricks/test/user/user_test.dart @@ -4,6 +4,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:formbricks/src/common/api_client.dart'; import 'package:formbricks/src/common/config.dart'; import 'package:formbricks/src/common/logger.dart'; +import 'package:formbricks/src/survey/embedded_data.dart'; import 'package:formbricks/src/user/update_queue.dart'; import 'package:formbricks/src/user/user.dart'; import 'package:http/http.dart' as http; @@ -172,4 +173,57 @@ void main() { ); }); }); + + /// The ambient Embedded Data bag survives a survey, so it has to be cleared + /// where identity changes — otherwise one user's context rides onto the next + /// user's responses on a shared device. Deliberately NOT cleared on first + /// identification: a host legitimately pushes context before it knows who the + /// user is. + group('Embedded Data bag on identity change', () { + final store = EmbeddedDataStore.instance; + + setUp(store.clear); + tearDown(store.clear); + + test('switching to a different userId clears the bag', () async { + final config = await _seed(userId: 'old'); + final queue = UpdateQueue.instance + ..configOverride = config + ..apiClientOverride = _noopApi(); + store.set({'plan': 'pro'}); + + await setUserId('new', config: config, queue: queue); + + expect(store.snapshot(), isEmpty); + }); + + test('identifying for the first time keeps the bag', () async { + final config = await _seed(); + final queue = UpdateQueue.instance..configOverride = config; + store.set({'plan': 'pro'}); + + await setUserId('u1', config: config, queue: queue); + + expect(store.snapshot(), {'plan': 'pro'}); + }); + + test('setting the same userId again keeps the bag', () async { + final config = await _seed(userId: 'u1'); + final queue = UpdateQueue.instance..configOverride = config; + store.set({'plan': 'pro'}); + + await setUserId('u1', config: config, queue: queue); + + expect(store.snapshot(), {'plan': 'pro'}); + }); + + test('logout clears the bag', () async { + final config = await _seed(userId: 'u1'); + store.set({'plan': 'pro'}); + + await logout(config: config); + + expect(store.snapshot(), isEmpty); + }); + }); } diff --git a/packages/formbricks/test/widgets/survey_html_test.dart b/packages/formbricks/test/widgets/survey_html_test.dart index bb1fdde..5c53d26 100644 --- a/packages/formbricks/test/widgets/survey_html_test.dart +++ b/packages/formbricks/test/widgets/survey_html_test.dart @@ -20,6 +20,7 @@ SurveyHtmlOptions _opts({ bool branding = true, String languageCode = 'default', String? contactId, + Map hiddenFieldsRecord = const {}, }) => SurveyHtmlOptions( survey: survey ?? _survey(), @@ -32,6 +33,7 @@ SurveyHtmlOptions _opts({ clickOutside: clickOutside, overlay: overlay, contactId: contactId, + hiddenFieldsRecord: hiddenFieldsRecord, ); void main() { @@ -172,4 +174,32 @@ void main() { expect(html, contains('Formbricks WebView Survey')); }); }); + + group('hiddenFieldsRecord', () { + test('rides the render options raw, including keys the survey ignores', () { + // The Embedded Data bag (ENG-1844/2472) uses the options blob that already + // exists — no new bridge message. The SDK does no filtering of its own: + // the renderer owns the allow-list, so an undeclared key must survive the + // trip and be dropped there, not here. + final html = buildSurveyHtml( + _opts( + hiddenFieldsRecord: const { + 'plan': 'pro', + 'notDeclaredBySurvey': 'kept — the renderer decides, not the SDK', + }, + ), + ); + + expect(html, contains('"hiddenFieldsRecord":{')); + expect(html, contains('"plan":"pro"')); + expect(html, contains('"notDeclaredBySurvey"')); + }); + + test('is an empty object when the host set nothing', () { + expect( + buildSurveyHtml(_opts()), + contains('"hiddenFieldsRecord":{}'), + ); + }); + }); } From 6565b2b793c2bcf30c9fcae2bee6dd9d2bc53031 Mon Sep 17 00:00:00 2001 From: Javi Aguilar <122741+itsjavi@users.noreply.github.com> Date: Mon, 31 Aug 2026 10:55:08 +0000 Subject: [PATCH 2/2] fix: make the clear-everything sentinel unforgeable, cover the pipe [ENG-2472] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../lib/src/survey/embedded_data.dart | 33 ++++++ .../lib/src/widgets/formbricks_widget.dart | 25 ++++- .../test/survey/embedded_data_test.dart | 105 ++++++++++++++++++ .../test/widgets/survey_webview_test.dart | 52 +++++++++ 4 files changed, 211 insertions(+), 4 deletions(-) diff --git a/packages/formbricks/lib/src/survey/embedded_data.dart b/packages/formbricks/lib/src/survey/embedded_data.dart index d7dc333..8589b16 100644 --- a/packages/formbricks/lib/src/survey/embedded_data.dart +++ b/packages/formbricks/lib/src/survey/embedded_data.dart @@ -53,10 +53,14 @@ class EmbeddedDataStore { /// `NaN`/`Infinity`, and the payload it would refuse is the whole survey's /// render options, so one bad value would cost the survey, not the field. void set(Map values) { + final setKeys = []; + final removedKeys = []; + for (final entry in values.entries) { final value = entry.value; if (value == null) { _data.remove(entry.key); + removedKeys.add(entry.key); continue; } if (value is num && !value.isFinite) { @@ -77,17 +81,46 @@ class EmbeddedDataStore { continue; } _data[entry.key] = value; + setKeys.add(entry.key); } + + _traceSet(setKeys, removedKeys); + } + + /// A success trace, because the bag is otherwise invisible: it lives in memory + /// (nothing in `SharedPreferences` 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. Debug level, so it is silent unless + /// `setup` was given [LogLevel.debug]. + /// + /// Keys only, never values: the documented use of this bag includes hashed + /// identity fields, and `Logger` is explicitly a no-PII channel. + void _traceSet(List setKeys, List removedKeys) { + final removed = + removedKeys.isEmpty ? '' : ', removed [${removedKeys.join(', ')}]'; + Logger.debug( + 'setEmbeddedData: set [${setKeys.join(', ')}]$removed — the bag now ' + 'holds [${_data.keys.join(', ')}]. 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. void remove(String key) { _data.remove(key); + Logger.debug( + 'clearEmbeddedData: removed "$key" — the bag now holds ' + '[${_data.keys.join(', ')}]', + ); } /// Removes everything — logout, or a hard context switch. void clear() { + final clearedCount = _data.length; _data.clear(); + Logger.debug( + 'clearEmbeddedData: cleared the whole bag ($clearedCount keys)', + ); } /// A detached, JSON-safe copy for the display-time snapshot: mutating the bag diff --git a/packages/formbricks/lib/src/widgets/formbricks_widget.dart b/packages/formbricks/lib/src/widgets/formbricks_widget.dart index aff980b..c8825d6 100644 --- a/packages/formbricks/lib/src/widgets/formbricks_widget.dart +++ b/packages/formbricks/lib/src/widgets/formbricks_widget.dart @@ -23,6 +23,22 @@ import 'default_webview_host.dart'; import 'survey_webview.dart'; import 'webview_navigation.dart'; +/// The type of the "no argument" marker for [Formbricks.clearEmbeddedData]. +/// +/// Library-private on purpose. A default parameter value must be a +/// compile-time constant, and Dart canonicalizes const instances — so a +/// `const Object()` marker is `identical` to *every* `const Object()` in the +/// program, and `clearEmbeddedData(const Object())` from host code would wipe +/// the whole bag instead of being refused. Naming a private type is the one +/// thing a caller outside this library cannot do, so a const instance of +/// `_ClearWholeBag` is both a valid default value and unforgeable. +class _ClearWholeBag { + const _ClearWholeBag(); +} + +/// The "no argument" marker for [Formbricks.clearEmbeddedData]. +const Object _clearWholeBag = _ClearWholeBag(); + /// The Formbricks SDK facade and drop-in host widget. /// /// Place `Formbricks(appUrl: ..., workspaceId: ...)` in your widget tree: it @@ -30,10 +46,6 @@ import 'webview_navigation.dart'; /// modal WebView route. The imperative API (`setup`, `track`) lives as static /// methods that route through a hidden command queue so calls run in strict /// submission order. -/// The "no argument" marker for [Formbricks.clearEmbeddedData]. A private const -/// object, so no caller can produce a value `identical` to it by accident. -const Object _clearWholeBag = Object(); - class Formbricks extends StatefulWidget { /// Creates the host widget for [appUrl] / [workspaceId]. const Formbricks({ @@ -199,6 +211,11 @@ class Formbricks extends StatefulWidget { /// JS SDK draws by argument count. A host that reads the key from its own /// state (`clearEmbeddedData(prefs['fieldToClear'])`) must not wipe the whole /// bag when that state is empty; that call is a logged no-op. + /// + /// The sentinel is a const instance of the private [_ClearWholeBag] rather + /// than a `const Object()`: const instances are canonicalized, so the latter + /// would make `clearEmbeddedData(const Object())` from host code an accidental + /// clear-everything. static void clearEmbeddedData([Object? key = _clearWholeBag]) { if (identical(key, _clearWholeBag)) { EmbeddedDataStore.instance.clear(); diff --git a/packages/formbricks/test/survey/embedded_data_test.dart b/packages/formbricks/test/survey/embedded_data_test.dart index deb79f9..3f444d5 100644 --- a/packages/formbricks/test/survey/embedded_data_test.dart +++ b/packages/formbricks/test/survey/embedded_data_test.dart @@ -1,10 +1,27 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:formbricks/formbricks.dart'; import 'package:formbricks/src/common/logger.dart'; import 'package:formbricks/src/survey/embedded_data.dart'; +/// Captures the lines `Logger` writes while [body] runs. `Logger` routes through +/// `debugPrint`, which `flutter_test` lets us swap for the duration of a test. +List _captureLogs(void Function() body) { + final lines = []; + final original = debugPrint; + debugPrint = (String? message, {int? wrapWidth}) { + if (message != null) lines.add(message); + }; + try { + body(); + } finally { + debugPrint = original; + } + return lines; +} + /// 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 all /// four SDKs share, so a divergence here is a divergence from the JS SDK too. @@ -190,4 +207,92 @@ void main() { expect(store.snapshot(), {'plan': 'pro'}); }); }); + + group('the debug success trace — the bag\'s only success feedback', () { + setUp(() => Logger.configure(level: LogLevel.debug)); + + test('a successful set logs the keys — keys only, never values', () { + final lines = _captureLogs( + () => Formbricks.setEmbeddedData({ + 'plan': 'pro', + 'hashed_email': 's3cret-hash', + }), + ); + + expect(lines, hasLength(1)); + expect(lines.single, contains('set [plan, hashed_email]')); + expect(lines.single, contains('the bag now holds [plan, hashed_email]')); + // The bag's documented use includes hashed identity fields; a value must + // never reach a log line. + expect(lines.single, isNot(contains('pro'))); + expect(lines.single, isNot(contains('s3cret-hash'))); + }); + + test('a null removal shows up as removed, not set', () { + Formbricks.setEmbeddedData({'plan': 'pro'}); + + final lines = _captureLogs( + () => Formbricks.setEmbeddedData({'plan': null, 'screen': 'checkout'}), + ); + + expect(lines.single, contains('set [screen]')); + expect(lines.single, contains('removed [plan]')); + expect(lines.single, contains('the bag now holds [screen]')); + }); + + test('a skipped value appears in neither list', () { + final lines = _captureLogs( + () => Formbricks.setEmbeddedData({'plan': 'pro', 'x': double.nan}), + ); + + // The refusal logs its own error line; the trace is the second. + expect(lines, hasLength(2)); + expect(lines.last, contains('set [plan]')); + expect(lines.last, isNot(contains('[plan, x]'))); + }); + + test('clearEmbeddedData traces both forms', () { + Formbricks.setEmbeddedData({'plan': 'pro', 'screen': 'product'}); + + final removedLines = + _captureLogs(() => Formbricks.clearEmbeddedData('plan')); + expect(removedLines.single, contains('removed "plan"')); + expect(removedLines.single, contains('the bag now holds [screen]')); + + final clearedLines = _captureLogs(Formbricks.clearEmbeddedData); + expect(clearedLines.single, contains('cleared the whole bag (1 keys)')); + }); + + test('the trace is silent at the default error level', () { + Logger.resetInstance(); + + final lines = _captureLogs( + () => Formbricks.setEmbeddedData({'plan': 'pro'}), + ); + + expect(lines, isEmpty); + }); + }); + + group('the clear-everything sentinel', () { + test('an outside const Object() cannot forge it', () { + // Dart canonicalizes const instances, so a `const Object()` sentinel would + // be identical to every `const Object()` in the program and this call + // would wipe the bag. The sentinel is a const instance of a private class + // instead, which host code cannot name. + Formbricks.setEmbeddedData({'plan': 'pro', 'screen': 'product'}); + + Formbricks.clearEmbeddedData(const Object()); + + expect(store.snapshot(), {'plan': 'pro', 'screen': 'product'}); + }); + + test('a non-const Object() cannot forge it either', () { + Formbricks.setEmbeddedData({'plan': 'pro'}); + + Formbricks.clearEmbeddedData(Object()); + + expect(store.snapshot(), {'plan': 'pro'}); + }); + }); } diff --git a/packages/formbricks/test/widgets/survey_webview_test.dart b/packages/formbricks/test/widgets/survey_webview_test.dart index 0359aa6..6691a2f 100644 --- a/packages/formbricks/test/widgets/survey_webview_test.dart +++ b/packages/formbricks/test/widgets/survey_webview_test.dart @@ -5,6 +5,7 @@ import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:formbricks/src/common/config.dart'; import 'package:formbricks/src/common/logger.dart'; +import 'package:formbricks/src/survey/embedded_data.dart'; import 'package:formbricks/src/survey/survey_store.dart'; import 'package:formbricks/src/types/survey.dart'; import 'package:formbricks/src/user/update_queue.dart'; @@ -150,6 +151,57 @@ void main() { Logger.resetInstance(); SurveyStore.resetInstance(); FormbricksConfig.resetInstance(); + EmbeddedDataStore.instance.clear(); + }); + + tearDown(EmbeddedDataStore.instance.clear); + + group('the Embedded Data pipe', () { + // `_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 are each + // tested in isolation elsewhere, so without these two the line could be + // deleted with every other test still green — and its failure mode is + // silent: the survey renders, the values just never arrive. + testWidgets('the display-time snapshot reaches the WebView html', + (tester) async { + await _seedConfig(); + EmbeddedDataStore.instance.set({'plan': 'pro', 'screen': 'checkout'}); + + final host = await _present( + tester, + _survey({'id': 's1', 'languages': []}), + ); + + expect( + host.html, + contains('"hiddenFieldsRecord":{"plan":"pro","screen":"checkout"}'), + ); + }); + + testWidgets('a value set during the delay still reaches the survey', + (tester) async { + // Pins "read at display, not at mount": the bag is empty when the widget + // mounts and only filled while the delay timer runs. + await _seedConfig(); + final survey = + _survey({'id': 's1', 'delay': 2, 'languages': []}); + final host = _StubHost(); + SurveyStore.instance.setSurvey(survey); + await tester.pumpWidget( + MaterialApp( + home: SurveyWebView(survey: survey, webViewHostBuilder: host.build), + ), + ); + await tester.pump(); // _start arms the timer + expect(host.html, isNull); + + EmbeddedDataStore.instance.set({'plan': 'pro'}); + await tester.pump(const Duration(seconds: 2)); // timer fires + await tester.pump(); // route push + + expect(host.html, contains('"hiddenFieldsRecord":{"plan":"pro"}')); + }); }); testWidgets('presents a single-language survey immediately', (tester) async {