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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
138 changes: 138 additions & 0 deletions packages/formbricks/lib/src/survey/embedded_data.dart
Original file line number Diff line number Diff line change
@@ -0,0 +1,138 @@
/// 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<String, Object> _data = <String, Object>{};

/// 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<String, Object?> values) {
final setKeys = <String>[];
final removedKeys = <String>[];

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) {
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;
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<String> setKeys, List<String> 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
/// 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<String, Object> snapshot() => <String, Object>{
for (final entry in _data.entries)
entry.key: entry.value is DateTime
? (entry.value as DateTime).toUtc().toIso8601String()
: entry.value,
};
}
9 changes: 9 additions & 0 deletions packages/formbricks/lib/src/user/user.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down Expand Up @@ -45,6 +46,11 @@ Future<Result<void, FormbricksError>> 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);
Expand All @@ -65,5 +71,8 @@ Future<Result<void, FormbricksError>> 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);
}
77 changes: 77 additions & 0 deletions packages/formbricks/lib/src/widgets/formbricks_widget.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -22,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
Expand Down Expand Up @@ -154,6 +171,66 @@ 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<String, Object?> 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.
///
/// 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();
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<Result<void, FormbricksError>> logout() {
Expand Down
9 changes: 9 additions & 0 deletions packages/formbricks/lib/src/widgets/survey_html.dart
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ class SurveyHtmlOptions {
this.placement,
this.clickOutside,
this.overlay,
this.hiddenFieldsRecord = const <String, Object>{},
});

/// The survey to render.
Expand Down Expand Up @@ -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<String, Object> hiddenFieldsRecord;

/// The `renderSurvey` options object.
Map<String, dynamic> toRenderOptions() => {
'workspaceId': workspaceId,
Expand All @@ -73,6 +81,7 @@ class SurveyHtmlOptions {
if (clickOutside != null) 'clickOutside': clickOutside,
if (overlay != null) 'overlay': overlay,
'isWebEnvironment': false,
'hiddenFieldsRecord': hiddenFieldsRecord,
};
}

Expand Down
6 changes: 6 additions & 0 deletions packages/formbricks/lib/src/widgets/survey_webview.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -164,6 +165,11 @@ class _SurveyWebViewState extends State<SurveyWebView> {
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(),
),
);

Expand Down
Loading