From 0bec0f13d18b75e3a8f83b3e3f50c776ba6afc2e Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Tue, 21 Jul 2026 12:50:15 +0530 Subject: [PATCH 1/5] fixes the js payload backtick bug --- packages/react-native/src/components/survey-web-view.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/react-native/src/components/survey-web-view.tsx b/packages/react-native/src/components/survey-web-view.tsx index 36a74cd..2e454e8 100644 --- a/packages/react-native/src/components/survey-web-view.tsx +++ b/packages/react-native/src/components/survey-web-view.tsx @@ -372,7 +372,11 @@ const renderHtml = ( function getSetIsError() { /* noop */ }; function loadSurvey() { - const options = ${JSON.stringify(options)}; + // Escape "<" so survey content can't inject "" (or " Date: Tue, 21 Jul 2026 13:02:06 +0530 Subject: [PATCH 2/5] refactor: satisfy sonar for payload escaping (ENG-1813) Move the "<" escaping into a `const optionsJson` before the template and use `replaceAll` + `String.raw` instead of `replace(/. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../src/components/survey-web-view.tsx | 15 ++++++++++----- 1 file changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/react-native/src/components/survey-web-view.tsx b/packages/react-native/src/components/survey-web-view.tsx index 2e454e8..7f5502d 100644 --- a/packages/react-native/src/components/survey-web-view.tsx +++ b/packages/react-native/src/components/survey-web-view.tsx @@ -336,6 +336,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 ` @@ -372,11 +381,7 @@ const renderHtml = ( function getSetIsError() { /* noop */ }; function loadSurvey() { - // Escape "<" so survey content can't inject "" (or " Date: Tue, 21 Jul 2026 14:13:22 +0530 Subject: [PATCH 3/5] run checks From 7d93929fe57b246f56615bfc5798e19b02e0cebe Mon Sep 17 00:00:00 2001 From: pandeymangg Date: Wed, 5 Aug 2026 18:11:10 +0530 Subject: [PATCH 4/5] feat: support survey-interaction segment filters (ENG-1275) Ports the client half of the web SDK change for interaction-based segment filters ("have seen X", "have completed X", ...). Membership for those filters is computed server-side and can flip the moment a contact interacts with a survey, so the SDK now refetches user state instead of waiting for it to expire. - Add the per-survey `interactionRefresh` gate to `TSurvey`. - Add `refreshSegmentsAfterInteraction`, mirroring js-core: no-op for anonymous users, no-op unless the server flagged that survey and event, otherwise nudge the UpdateQueue so a display -> response -> finish burst debounces into one request. - Bridge `onFinished` from the WebView. The prop and the zod flag already existed but nothing ever fired or read them, so "have completed X" had no client-side trigger. - Wire the refresh into all three lifecycle branches of the WebView's onMessage handler, after the local optimistic config update. `renderHtml` is exported so the harness can be asserted in tests; it is not re-exported from the package entry point. `__DEV__` is stubbed in the vitest setup because React Native injects it at build time and modules that branch on it fail to import otherwise. --- .../src/components/survey-web-view.tsx | 41 +++++- .../tests/survey-web-view-harness.test.ts | 57 +++++++++ .../src/lib/user/interaction-refresh.ts | 42 +++++++ .../user/tests/interaction-refresh.test.ts | 119 ++++++++++++++++++ packages/react-native/src/types/survey.ts | 8 ++ packages/react-native/vitest.setup.ts | 3 + 6 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 packages/react-native/src/components/tests/survey-web-view-harness.test.ts create mode 100644 packages/react-native/src/lib/user/interaction-refresh.ts create mode 100644 packages/react-native/src/lib/user/tests/interaction-refresh.test.ts diff --git a/packages/react-native/src/components/survey-web-view.tsx b/packages/react-native/src/components/survey-web-view.tsx index 7f5502d..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); @@ -377,6 +408,13 @@ 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 */ }; @@ -386,6 +424,7 @@ const renderHtml = ( ...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("