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
52 changes: 50 additions & 2 deletions packages/react-native/src/components/survey-web-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { RNConfig } from "@/lib/common/config";
import { Logger } from "@/lib/common/logger";
import { filterSurveys, getLanguageCode, getStyling } from "@/lib/common/utils";
import { SurveyStore } from "@/lib/survey/store";
import { refreshSegmentsAfterInteraction } from "@/lib/user/interaction-refresh";
import { type TUserState, ZJsRNWebViewOnMessageData } from "@/types/config";
import type { SurveyContainerProps, TSurvey } from "@/types/survey";

Expand Down Expand Up @@ -196,6 +197,7 @@ export function SurveyWebView(props: SurveyWebViewProps): JSX.Element | null {
const {
onClose,
onDisplayCreated,
onFinished,
onOpenExternalURL,
onOpenExternalURLParams,
onResponseCreated,
Expand Down Expand Up @@ -230,6 +232,16 @@ export function SurveyWebView(props: SurveyWebViewProps): JSX.Element | null {
user: updatedUserState,
filteredSurveys,
});

// A new display can flip "have seen X" / "have not seen X" segments. The
// optimistic update above keeps recontact/display-cap correct locally; this
// pulls fresh `segments` (gated + coalesced) so interaction targeting is
// current by the time this survey closes and the next trigger evaluates.
refreshSegmentsAfterInteraction(
previousConfig.user.data.userId,
props.survey,
"onDisplay",
);
}
if (onResponseCreated) {
const responses = appConfig.get().user.data.responses;
Expand All @@ -252,6 +264,24 @@ export function SurveyWebView(props: SurveyWebViewProps): JSX.Element | null {
user: newPersonState,
filteredSurveys,
});

// A created response flips "have started responding to X" segments. The
// "completed X" case is handled by onFinished below.
refreshSegmentsAfterInteraction(
appConfig.get().user.data.userId,
props.survey,
"onResponse",
);
}
if (onFinished) {
// Survey completion flips "have completed X" (and clears "have not completed
// X") segments. The surveys library only fires this after the finished
// response has been sent, so the server recompute sees finished=true.
refreshSegmentsAfterInteraction(
appConfig.get().user.data.userId,
props.survey,
"onFinished",
);
}
if (onOpenExternalURL && onOpenExternalURLParams?.url) {
void openExternalUrl(onOpenExternalURLParams.url);
Expand Down Expand Up @@ -317,7 +347,8 @@ const styles = StyleSheet.create({
},
});

const renderHtml = (
/** Exported for tests — not re-exported from the package entry point. */
export const renderHtml = (
options: Partial<SurveyContainerProps> & { appUrl?: string },
): string => {
const surveyScriptUrl = getSurveyScriptUrl(options.appUrl);
Expand All @@ -336,6 +367,15 @@ const renderHtml = (
`;
}

// Escape "<" so survey content can't inject "</script>" (or "<script"/"<!--") and break
// out of the inline <script> below: "<" occurs only inside JSON string values, and the
// WebView's JS engine decodes the escaped "<" back to a literal "<" when parsing the
// object literal, so the payload is preserved exactly. See ENG-1813.
const optionsJson = JSON.stringify(options).replaceAll(
"<",
String.raw`\u003c`,
);

return `
<!doctype html>
<html>
Expand Down Expand Up @@ -368,15 +408,23 @@ const renderHtml = (
window.ReactNativeWebView.postMessage(JSON.stringify({ onResponseCreated: true }));
};

// Fires once the finished response has been accepted by the backend — the surveys library
// gates this on \`isResponseSendingFinished\`, and \`getSetIsResponseSendingFinished\` below
// flips that initial state to false.
function onFinished() {
window.ReactNativeWebView.postMessage(JSON.stringify({ onFinished: true }));
};

function getSetIsResponseSendingFinished() { /* noop — presence flips initial state to false so loading spinner renders until ResponseQueue resolves */ };
function getSetIsError() { /* noop */ };

function loadSurvey() {
const options = ${JSON.stringify(options)};
const options = ${optionsJson};
const surveyProps = {
...options,
onDisplayCreated,
onResponseCreated,
onFinished,
onClose,
getSetIsResponseSendingFinished,
getSetIsError,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { describe, expect, test, vi } from "vitest";
import { renderHtml } from "@/components/survey-web-view";

// The shared setup mocks `react-native` down to `Platform` only; this module also reaches for
// view primitives and calls `StyleSheet.create` at import time, so widen the mock here.
vi.mock("react-native", () => ({
Platform: { OS: "ios" },
KeyboardAvoidingView: () => null,
Linking: { openURL: vi.fn() },
Modal: () => null,
StyleSheet: { create: (styles: unknown) => styles },
View: () => null,
}));

const harness = (appUrl = "https://app.formbricks.com"): string =>
renderHtml({ appUrl, workspaceId: "ws-1" });

describe("WebView harness", () => {
test("defines an onFinished bridge function", () => {
expect(harness()).toContain("function onFinished()");
});

test("posts the onFinished message back to the host", () => {
expect(harness()).toContain(
"window.ReactNativeWebView.postMessage(JSON.stringify({ onFinished: true }))",
);
});

/**
* Defining the function is not enough — the surveys library only calls it if it is handed in
* as a prop. Without this assertion the harness could define onFinished and never wire it up.
*/
test("passes onFinished into renderSurvey's props", () => {
const html = harness();
const propsBlock = html.slice(
html.indexOf("const surveyProps = {"),
html.indexOf("window.formbricksSurveys.renderSurvey"),
);

expect(propsBlock).toContain("onFinished,");
// The other lifecycle props must survive alongside it.
expect(propsBlock).toContain("onDisplayCreated,");
expect(propsBlock).toContain("onResponseCreated,");
expect(propsBlock).toContain("onClose,");
});

test("still escapes < in the payload so survey content cannot break out of the script", () => {
const html = renderHtml({
appUrl: "https://app.formbricks.com",
workspaceId: "</script><script>alert(1)</script>",
});

expect(html).not.toContain("</script><script>alert(1)");
// Each "<" is emitted as the literal six-character sequence \u003c.
expect(html).toContain("\\u003c/script>");
});
});
45 changes: 45 additions & 0 deletions packages/react-native/src/lib/user/interaction-refresh.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { Logger } from "@/lib/common/logger";
import { UpdateQueue } from "@/lib/user/update-queue";
import type { TSurvey } from "@/types/survey";

const logger = Logger.getInstance();

export type TInteractionSource = keyof NonNullable<
TSurvey["interactionRefresh"]
>;

/**
* Refresh server-computed segment membership after a survey interaction (display / response /
* finish).
*
* A `surveyInteraction` segment filter can change who a contact is the moment they interact with
* a survey (e.g. "have seen X", "have completed X"), so we pull fresh `segments` instead of
* waiting for the user-state TTL. But that refresh is a heavy `/user` recompute, so it is:
* - Gated per survey and per event via `survey.interactionRefresh`: only interactions that can
* actually change some live survey's membership trigger a refetch. A survey referenced only
* by a "have seen" filter refreshes on display but not on response/finish, and a survey no
* interaction filter references never refreshes.
* - Routed through the UpdateQueue rather than a raw `sendUpdates`, so the display -> response
* -> finish burst coalesces into a single debounced call.
*
* No-op for anonymous users (no `userId`) and when the interaction can't change membership.
*/
export const refreshSegmentsAfterInteraction = (
userId: string | null,
survey: TSurvey,
source: TInteractionSource,
): void => {
if (!userId) return;

const shouldRefresh = survey.interactionRefresh?.[source] ?? false;
if (!shouldRefresh) return;

logger.debug(`Refreshing segments after ${source} on survey ${survey.id}`);

const updateQueue = UpdateQueue.getInstance();
updateQueue.updateUserId(userId);
// `processUpdates` rejects if the flush throws, and this is fire-and-forget — a bare
// `void` would leave that as an unhandled rejection. The queue already logs the real
// cause, so swallow it here rather than reporting it twice.
void updateQueue.processUpdates().catch(() => undefined);
};
158 changes: 158 additions & 0 deletions packages/react-native/src/lib/user/tests/interaction-refresh.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { beforeEach, describe, expect, test, vi } from "vitest";
import { refreshSegmentsAfterInteraction } from "@/lib/user/interaction-refresh";
import { UpdateQueue } from "@/lib/user/update-queue";
import type { TSurvey } from "@/types/survey";

const updateUserId = vi.fn();
const processUpdates = vi.fn().mockResolvedValue(undefined);

vi.mock("@/lib/user/update-queue", () => ({
UpdateQueue: {
getInstance: vi.fn(() => ({
updateUserId,
processUpdates,
})),
},
}));

const survey = (interactionRefresh?: TSurvey["interactionRefresh"]): TSurvey =>
({
id: "survey-a",
interactionRefresh,
}) as unknown as TSurvey;

describe("refreshSegmentsAfterInteraction", () => {
beforeEach(() => {
vi.clearAllMocks();
// The shared vitest setup calls `resetAllMocks`, which strips implementations — so the
// resolved value has to be re-established or `processUpdates()` returns undefined.
processUpdates.mockResolvedValue(undefined);
});

test("no-ops for an anonymous user even when the gate is open", () => {
refreshSegmentsAfterInteraction(
null,
survey({ onDisplay: true, onResponse: true, onFinished: true }),
"onDisplay",
);

expect(UpdateQueue.getInstance).not.toHaveBeenCalled();
expect(updateUserId).not.toHaveBeenCalled();
expect(processUpdates).not.toHaveBeenCalled();
});

test("no-ops when interactionRefresh is absent — the workspace has no interaction targeting", () => {
refreshSegmentsAfterInteraction("user-1", survey(undefined), "onDisplay");

expect(updateUserId).not.toHaveBeenCalled();
expect(processUpdates).not.toHaveBeenCalled();
});

test("no-ops when every flag is false — no interaction filter references this survey", () => {
refreshSegmentsAfterInteraction(
"user-1",
survey({ onDisplay: false, onResponse: false, onFinished: false }),
"onDisplay",
);

expect(updateUserId).not.toHaveBeenCalled();
expect(processUpdates).not.toHaveBeenCalled();
});

test("no-ops when the flag for a different source is set", () => {
refreshSegmentsAfterInteraction(
"user-1",
survey({ onDisplay: true, onResponse: false, onFinished: false }),
"onResponse",
);

expect(updateUserId).not.toHaveBeenCalled();
expect(processUpdates).not.toHaveBeenCalled();
});

test.each([
[
"onDisplay" as const,
{ onDisplay: true, onResponse: false, onFinished: false },
],
[
"onResponse" as const,
{ onDisplay: false, onResponse: true, onFinished: false },
],
[
"onFinished" as const,
{ onDisplay: false, onResponse: false, onFinished: true },
],
])("refreshes for %s when its flag is set", (source, flags) => {
refreshSegmentsAfterInteraction("user-1", survey(flags), source);

expect(updateUserId).toHaveBeenCalledExactlyOnceWith("user-1");
expect(processUpdates).toHaveBeenCalledOnce();
});

test("a partial gate object treats missing flags as false", () => {
const partial = { onDisplay: true } as NonNullable<
TSurvey["interactionRefresh"]
>;

refreshSegmentsAfterInteraction("user-1", survey(partial), "onFinished");
expect(processUpdates).not.toHaveBeenCalled();

refreshSegmentsAfterInteraction("user-1", survey(partial), "onDisplay");
expect(processUpdates).toHaveBeenCalledOnce();
});

/**
* The flush is fire-and-forget, so a bare `void` would leave a rejection unhandled. Asserting
* that `.catch` is called is white-box, but Node only reports an unhandled rejection on a
* later tick, so watching `process.on("unhandledRejection")` here passes either way — it does
* not actually pin the behaviour.
*/
test("attaches a rejection handler to the fire-and-forget flush", () => {
const catchSpy = vi.fn().mockReturnValue(undefined);
processUpdates.mockReturnValueOnce({
catch: catchSpy,
} as unknown as Promise<void>);

refreshSegmentsAfterInteraction(
"user-1",
survey({ onDisplay: true, onResponse: false, onFinished: false }),
"onDisplay",
);

expect(catchSpy).toHaveBeenCalledOnce();
});

test("a rejected flush never surfaces to the caller", async () => {
processUpdates.mockRejectedValueOnce(new Error("flush blew up"));

expect(() => {
refreshSegmentsAfterInteraction(
"user-1",
survey({ onDisplay: true, onResponse: false, onFinished: false }),
"onDisplay",
);
}).not.toThrow();

await Promise.resolve();
expect(processUpdates).toHaveBeenCalledOnce();
});

test("routes through the queue rather than sending directly, so a burst coalesces", () => {
const allOn = survey({
onDisplay: true,
onResponse: true,
onFinished: true,
});

refreshSegmentsAfterInteraction("user-1", allOn, "onDisplay");
refreshSegmentsAfterInteraction("user-1", allOn, "onResponse");
refreshSegmentsAfterInteraction("user-1", allOn, "onFinished");

// Three nudges, three queue pokes — the queue's own debounce is what collapses them into
// one request, which is covered by update-queue.test.ts.
expect(updateUserId).toHaveBeenCalledTimes(3);
expect(processUpdates).toHaveBeenCalledTimes(3);
expect(updateUserId).toHaveBeenLastCalledWith("user-1");
});
});
8 changes: 8 additions & 0 deletions packages/react-native/src/types/survey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -313,4 +313,12 @@ export interface TSurvey {
} | null;
overwriteThemeStyling?: boolean | null;
};
// Per-survey gate for the post-interaction segment refresh: whether an interaction with THIS
// survey (display / response / finish) can change any live survey's membership. Absent for
// workspaces without interaction targeting, where no refresh is ever needed.
interactionRefresh?: {
onDisplay: boolean;
onResponse: boolean;
onFinished: boolean;
};
}
3 changes: 3 additions & 0 deletions packages/react-native/vitest.setup.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
import { afterEach, beforeEach, vi } from "vitest";

// React Native injects `__DEV__` at build time; modules that branch on it need it defined here.
vi.stubGlobal("__DEV__", false);

beforeEach(() => {
vi.resetModules();
vi.resetAllMocks();
Expand Down
Loading