Skip to content
Open
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
38 changes: 34 additions & 4 deletions packages/react-native/src/components/survey-web-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,11 @@ import { getSurveyScriptUrl } from "@/components/utils/survey-script-url";
import { RNConfig } from "@/lib/common/config";
import { Logger } from "@/lib/common/logger";
import { filterSurveys, getLanguageCode, getStyling } from "@/lib/common/utils";
import { EmbeddedDataStore } from "@/lib/survey/embedded-data";
import { SurveyStore } from "@/lib/survey/store";
import { refreshSegmentsAfterInteraction } from "@/lib/user/interaction-refresh";
import { type TUserState, ZJsRNWebViewOnMessageData } from "@/types/config";
import type { TIngestedFieldsRecord } from "@/types/response";
import type { SurveyContainerProps, TSurvey } from "@/types/survey";

const logger = Logger.getInstance();
Expand All @@ -31,6 +33,18 @@ export function SurveyWebView(props: SurveyWebViewProps): JSX.Element | null {
const [showSurvey, setShowSurvey] = useState(false);
const [appConfig, setAppConfig] = useState<RNConfig | null>(null);
const [languageCode, setLanguageCode] = useState("default");
/**
* The Embedded Data bag, snapshotted at the moment the survey is shown and frozen for the rest of
* its life (ENG-1844). Held in state rather than read inline in the render pass on purpose: the
* html string below is an *input* to the WebView, so reading a mutable bag while rendering would
* let a later `setEmbeddedData` rewrite `source` and reload the WebView mid-survey — losing the
* respondent's answers. A later write therefore reaches the next response, never this one.
*
* Set in the same update as `setShowSurvey(true)`, which is the delay-aware display moment
* js-core snapshots at too (it builds its own bag inside the delay timeout).
*/
const [embeddedDataSnapshot, setEmbeddedDataSnapshot] =
useState<TIngestedFieldsRecord>({});

useEffect(() => {
const fetchConfig = async (): Promise<void> => {
Expand Down Expand Up @@ -74,20 +88,31 @@ export function SurveyWebView(props: SurveyWebViewProps): JSX.Element | null {
return;
}

/**
* Shows the survey, taking the Embedded Data snapshot in the same update.
*
* Both calls belong together and in this order: React batches them into one render, so the
* WebView mounts with the bag already frozen rather than mounting empty and re-rendering with
* it — which would change `source` and reload the survey. Called directly, or from the delay
* timeout below, so the snapshot is always taken at the moment the survey actually appears.
*/
const display = (): void => {
setEmbeddedDataSnapshot(EmbeddedDataStore.getInstance().getSnapshot());
setShowSurvey(true);
};

if (props.survey.delay) {
logger.debug(
`Delaying survey "${props.survey.id}" by ${String(props.survey.delay)} seconds`,
);
const timerId = setTimeout(() => {
setShowSurvey(true);
}, props.survey.delay * 1000);
const timerId = setTimeout(display, props.survey.delay * 1000);

return () => {
clearTimeout(timerId);
};
}

setShowSurvey(true);
display();
}, [props.survey.delay, isSurveyRunning, props.survey.id]);

if (!appConfig) {
Expand Down Expand Up @@ -152,6 +177,11 @@ export function SurveyWebView(props: SurveyWebViewProps): JSX.Element | null {
clickOutside,
overlay,
isWebEnvironment: false,
// Passed straight through, unfiltered: the Embedded Data ingest contract lives in
// the renderer (ENG-1845/2472), so all four mobile SDKs inherit the same allow-list,
// coercion and size rules without each shipping a copy. The renderer drops unknown
// and locked keys and logs what it refused; the server re-runs all of it on ingest.
hiddenFieldsRecord: embeddedDataSnapshot,
}),
}}
style={styles.webView}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,26 @@ describe("WebView harness", () => {
expect(propsBlock).toContain("onClose,");
});

/**
* The Embedded Data bag (ENG-1844/2472) rides the props blob that already exists — no new bridge
* message. The blob is JSON, so this pins that the key survives serialization under the name the
* renderer reads, with the SDK doing no filtering of its own: the renderer owns the allow-list.
*/
test("carries hiddenFieldsRecord into the payload, raw and unfiltered", () => {
const html = renderHtml({
appUrl: "https://app.formbricks.com",
workspaceId: "ws-1",
hiddenFieldsRecord: {
plan: "pro",
notDeclaredBySurvey: "kept — the renderer decides, not the SDK",
},
});

expect(html).toContain('"hiddenFieldsRecord":{');
expect(html).toContain('"plan":"pro"');
expect(html).toContain('"notDeclaredBySurvey"');
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

test("still escapes < in the payload so survey content cannot break out of the script", () => {
const html = renderHtml({
appUrl: "https://app.formbricks.com",
Expand Down
32 changes: 32 additions & 0 deletions packages/react-native/src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import { CommandQueue } from "@/lib/common/command-queue";
import { Logger } from "@/lib/common/logger";
import * as Actions from "@/lib/survey/action";
import {
EmbeddedDataStore,
type TEmbeddedDataInput,
} from "@/lib/survey/embedded-data";
import * as Attributes from "@/lib/user/attribute";
import * as User from "@/lib/user/user";

Expand Down Expand Up @@ -43,4 +47,32 @@ export const logout = async (): Promise<void> => {
await queue.wait();
};

/**
* Attach Embedded Data to future responses without tying it to a trigger (ENG-1844). Merges into the
* in-memory bag, last write wins per key; `{ key: null }` removes a key and `undefined` values are
* skipped. Values land only on the survey's declared *ingested* fields — anything else is dropped
* and logged by the renderer, never fatal.
*
* Synchronous and network-free on purpose, unlike the queued methods above: calling it on every
* screen change is free, and routing it through the command queue would silently drop calls made
* before `setup()` completes — a host legitimately pushes context before the SDK is ready.
*
* ```ts
* setEmbeddedData({ screen: "checkout", plan: "pro" });
* setEmbeddedData({ screen: null }); // remove one key
* ```
*/
export const setEmbeddedData = (data: TEmbeddedDataInput): void => {
EmbeddedDataStore.getInstance().setEmbeddedData(data);
};

/**
* Remove one Embedded Data key, or clear the whole bag when called with no argument — logout, or a
* hard context switch. Synchronous, no network. A key that evaluated to `undefined` is a no-op, not
* a full clear: the arity is forwarded, so only a literal zero-argument call wipes everything.
*/
export const clearEmbeddedData = (...args: [] | [key: string]): void => {
EmbeddedDataStore.getInstance().clearEmbeddedData(...args);
};

export { Formbricks, Formbricks as default } from "@/components/formbricks";
129 changes: 129 additions & 0 deletions packages/react-native/src/lib/survey/embedded-data.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { Logger } from "@/lib/common/logger";
import type { TIngestedFieldsRecord } from "@/types/response";

/** What a host app may hand to `setEmbeddedData`. `null` removes the key; `undefined` is a no-op. */
export type TEmbeddedDataInput = Record<
string,
string | number | boolean | Date | null | undefined
>;

/**
* The in-memory Embedded Data bag (ENG-1844/2472): context a host app attaches to future responses
* without tying it to a trigger — `setEmbeddedData({ screen: "checkout" })` once, instead of
* repeating the same values on every possible `track()` call. Mirrors js-core's store so the web and
* mobile SDKs behave identically, key for key.
*
* Lifetime rules, all deliberate:
*
* - **In-memory, process scoped, never persisted.** Not `RNConfig`: that class writes to async
* storage, and 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
* `hiddenFieldsRecord` when the survey is shown; a later `setEmbeddedData` affects the next
* response, never the one on screen.
* - **No filtering here.** The SDK is a dumb pipe: the 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.
* - **No network.** Every method is a synchronous memory write, so calling `setEmbeddedData` on
* every screen change is free. Values ride the existing response payload.
*
* Backed by a `Map` rather than a plain object so a `__proto__` key is stored as data instead of
* vanishing into the prototype — the same hole the ingest contract closes on the renderer side.
*/
export class EmbeddedDataStore {
private static instance: EmbeddedDataStore | undefined;
private readonly data = new Map<string, string | number | boolean | Date>();

static getInstance(): EmbeddedDataStore {
EmbeddedDataStore.instance ??= new EmbeddedDataStore();
return EmbeddedDataStore.instance;
}

/**
* Merge — never replace — so refreshing a volatile field (`screen`) cannot wipe the stable ones
* (`plan`) set at launch. Per key: last write wins; `null` removes; `undefined` does nothing.
*
* The `undefined` no-op is a documented promise, not an accident: a host that builds the object
* from its own state passes every field unconditionally, so a key that is absent on the current
* screen arrives as `undefined` and must not clear the value a previous screen set.
*/
public setEmbeddedData(data: TEmbeddedDataInput): void {
// Guarded rather than thrown: this is a synchronous entry point outside the command queue's
// shield, and a host can legitimately hand over a value that was not there. A broken host build
// is a worse failure than a skipped write. An array is refused too (`typeof [] === "object"`):
// it would spread into junk numeric keys ({0: "a", 1: "b"}).
if (typeof data !== "object" || data === null || Array.isArray(data)) {
Logger.getInstance().error(
`setEmbeddedData: expected an object, got ${data === null ? "null" : typeof data} — nothing was set`,
);
return;
}

for (const [key, value] of Object.entries(data)) {
if (value === undefined) continue;
if (value === null) {
this.data.delete(key);
continue;
}
// Refused rather than stored: `toISOString()` throws a RangeError on an invalid Date, and
// `getSnapshot` runs inside the effect that displays the survey — so one `new Date("nope")`
// from host code would cost the survey, not the field. This guard exists here and not in
// js-core because serializing the Date is this SDK's own step: js-core hands the Date object
// straight to the renderer, which coerces it. Never fatal, always logged.
if (value instanceof Date && Number.isNaN(value.getTime())) {
Logger.getInstance().error(
`setEmbeddedData: "${key}" is an invalid Date — the key was skipped`,
);
continue;
}
this.data.set(key, value);
Comment thread
itsjavi marked this conversation as resolved.
}
}

/**
* Remove one key, or everything when called with no argument (logout / hard context switch).
*
* "No argument" and "an argument that evaluated to `undefined`" are deliberately different: a host
* reading the key from its own state must not wipe the whole bag when that state is empty, so
* only a literal zero-argument call clears everything.
*/
public clearEmbeddedData(...args: [] | [key: string]): void {
if (args.length === 0) {
this.data.clear();
return;
}

const [key] = args;
if (typeof key !== "string") {
Logger.getInstance().error(
"clearEmbeddedData: expected a field name — nothing was cleared (call with no argument to clear everything)",
);
return;
}

this.data.delete(key);
}

/**
* A detached copy for the display-time snapshot: mutating the bag after a survey rendered must not
* reach that survey's response. `Object.fromEntries` defines own properties, so a `__proto__` key
* survives the conversion as data.
*
* Dates are serialized here rather than at the JSON boundary: `renderHtml` stringifies the props
* blob, and an ISO 8601 string is exactly what the renderer's ingest contract accepts for a `date`
* field. `setEmbeddedData` refuses an invalid Date, so `toISOString` here cannot throw.
*
* The return type spells booleans rather than asserting them away: the bag really does hold them,
* the renderer's contract really does accept them, and only the legacy `TResponseData` could not
* say so.
*/
public getSnapshot(): TIngestedFieldsRecord {
return Object.fromEntries(
Array.from(this.data, ([key, value]) => [
key,
value instanceof Date ? value.toISOString() : value,
]),
);
}
}
Loading
Loading