diff --git a/packages/react-native/src/components/survey-web-view.tsx b/packages/react-native/src/components/survey-web-view.tsx index 36a74cd..47efcc9 100644 --- a/packages/react-native/src/components/survey-web-view.tsx +++ b/packages/react-native/src/components/survey-web-view.tsx @@ -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"; @@ -196,6 +197,7 @@ export function SurveyWebView(props: SurveyWebViewProps): JSX.Element | null { const { onClose, onDisplayCreated, + onFinished, onOpenExternalURL, onOpenExternalURLParams, onResponseCreated, @@ -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; @@ -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); @@ -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 & { appUrl?: string }, ): string => { const surveyScriptUrl = getSurveyScriptUrl(options.appUrl); @@ -336,6 +367,15 @@ const renderHtml = ( `; } + // Escape "<" so survey content can't inject "" (or " 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 ` @@ -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, diff --git a/packages/react-native/src/components/tests/survey-web-view-harness.test.ts b/packages/react-native/src/components/tests/survey-web-view-harness.test.ts new file mode 100644 index 0000000..844751c --- /dev/null +++ b/packages/react-native/src/components/tests/survey-web-view-harness.test.ts @@ -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: "", + }); + + expect(html).not.toContain("