diff --git a/packages/react-native/src/components/formbricks.tsx b/packages/react-native/src/components/formbricks.tsx
index e363f38..df3010a 100644
--- a/packages/react-native/src/components/formbricks.tsx
+++ b/packages/react-native/src/components/formbricks.tsx
@@ -1,6 +1,6 @@
import type React from "react";
import { useCallback, useEffect, useSyncExternalStore } from "react";
-import { View } from "react-native";
+import { StyleSheet, View } from "react-native";
import { SurveyWebView } from "@/components/survey-web-view";
import { Logger } from "@/lib/common/logger";
import { setup } from "@/lib/common/setup";
@@ -55,7 +55,9 @@ export function Formbricks({
// Wrap in View with pointerEvents="box-none" to fix Android touch event handling.
return survey ? (
-
+ // Fills the parent: a no-overlay survey renders in the host's view tree (not a Modal) and
+ // positions itself against this wrapper, which would otherwise be 0x0.
+
) : null;
diff --git a/packages/react-native/src/components/survey-web-view.tsx b/packages/react-native/src/components/survey-web-view.tsx
index 37b30ea..e38f2b2 100644
--- a/packages/react-native/src/components/survey-web-view.tsx
+++ b/packages/react-native/src/components/survey-web-view.tsx
@@ -1,13 +1,22 @@
import { type JSX, useEffect, useRef, useState } from "react";
import {
+ BackHandler,
+ Keyboard,
KeyboardAvoidingView,
Linking,
Modal,
+ Platform,
StyleSheet,
View,
} from "react-native";
import { WebView, type WebViewMessageEvent } from "react-native-webview";
import { getSurveyScriptUrl } from "@/components/utils/survey-script-url";
+import {
+ androidKeyboardPadding,
+ getPassthroughFrames,
+ parseCardRectMessage,
+ type TCardState,
+} from "@/components/utils/survey-touch-region";
import { RNConfig } from "@/lib/common/config";
import { Logger } from "@/lib/common/logger";
import { filterSurveys, getLanguageCode, getStyling } from "@/lib/common/utils";
@@ -29,6 +38,8 @@ interface SurveyWebViewProps {
export function SurveyWebView(props: SurveyWebViewProps): JSX.Element | null {
const webViewRef = useRef(null);
+ /** The no-overlay host area, measured for the Android keyboard padding; never padded itself. */
+ const hostRef = useRef(null);
const [isSurveyRunning, setIsSurveyRunning] = useState(false);
const [showSurvey, setShowSurvey] = useState(false);
const [appConfig, setAppConfig] = useState(null);
@@ -45,6 +56,32 @@ export function SurveyWebView(props: SurveyWebViewProps): JSX.Element | null {
*/
const [embeddedDataSnapshot, setEmbeddedDataSnapshot] =
useState({});
+ /** The card's state as the renderer reports it; see `TCardState` for why it has three values. */
+ const [cardState, setCardState] = useState(undefined);
+ /** The area a no-overlay survey renders over, so the WebView keeps exactly that size. */
+ const [hostSize, setHostSize] = useState({ width: 0, height: 0 });
+ /** Android only; see `androidKeyboardPadding`. */
+ const [androidKeyboardHeight, setAndroidKeyboardHeight] = useState(0);
+
+ useEffect(() => {
+ if (Platform.OS !== "android") return;
+ const subscriptions = [
+ Keyboard.addListener("keyboardDidShow", (event) => {
+ const keyboardTop = event.endCoordinates.screenY;
+ hostRef.current?.measureInWindow((_x, y, _width, height) => {
+ setAndroidKeyboardHeight(
+ androidKeyboardPadding(y + height, keyboardTop),
+ );
+ });
+ }),
+ Keyboard.addListener("keyboardDidHide", () => {
+ setAndroidKeyboardHeight(0);
+ }),
+ ];
+ return () => {
+ for (const subscription of subscriptions) subscription.remove();
+ };
+ }, []);
useEffect(() => {
const fetchConfig = async (): Promise => {
@@ -146,192 +183,281 @@ export function SurveyWebView(props: SurveyWebViewProps): JSX.Element | null {
const overlay = props.survey.projectOverwrites?.overlay ?? settings.overlay;
const appUrl = appConfig.get().appUrl;
- return (
- {
- setShowSurvey(false);
- setIsSurveyRunning(false);
+ const isPassThrough = (overlay ?? "none") === "none";
+
+ const webView = (
+ {
+ if (isAllowedWebViewNavigation(event.url, appUrl)) {
+ return true;
+ }
+
+ void openExternalUrl(event.url);
+ return false;
}}
+ onMessage={(event: WebViewMessageEvent) => {
+ try {
+ const { data } = event.nativeEvent;
+ const unvalidatedMessage = JSON.parse(data) as {
+ type: string;
+ data: unknown;
+ };
+
+ if (unvalidatedMessage.type === "CardRect") {
+ const card = parseCardRectMessage(unvalidatedMessage.data);
+ if (card === undefined) {
+ logger.error("Error parsing card rect from WebView.");
+ return;
+ }
+ setCardState(card);
+ return;
+ }
+
+ // debugger
+ if (unvalidatedMessage.type === "Console") {
+ if (__DEV__) {
+ console.info(
+ `[Console] ${JSON.stringify(unvalidatedMessage.data)}`,
+ );
+ }
+ return;
+ }
+
+ const validatedMessage =
+ ZJsRNWebViewOnMessageData.safeParse(unvalidatedMessage);
+ if (!validatedMessage.success) {
+ logger.error("Error parsing message from WebView.");
+ return;
+ }
+
+ const {
+ onClose,
+ onDisplayCreated,
+ onFinished,
+ onOpenExternalURL,
+ onOpenExternalURLParams,
+ onResponseCreated,
+ } = validatedMessage.data;
+ if (onDisplayCreated) {
+ const existingDisplays = appConfig.get().user.data.displays;
+ const newDisplay = {
+ surveyId: props.survey.id,
+ createdAt: new Date(),
+ };
+
+ const displays = [...existingDisplays, newDisplay];
+ const previousConfig = appConfig.get();
+
+ const updatedUserState = {
+ ...previousConfig.user,
+ data: {
+ ...previousConfig.user.data,
+ displays,
+ lastDisplayAt: new Date(),
+ },
+ };
+
+ const filteredSurveys = filterSurveys(
+ previousConfig.workspace,
+ updatedUserState,
+ );
+
+ appConfig.update({
+ ...previousConfig,
+ workspace: previousConfig.workspace,
+ 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;
+ const newPersonState: TUserState = {
+ ...appConfig.get().user,
+ data: {
+ ...appConfig.get().user.data,
+ responses: [...responses, props.survey.id],
+ },
+ };
+
+ const filteredSurveys = filterSurveys(
+ appConfig.get().workspace,
+ newPersonState,
+ );
+
+ appConfig.update({
+ ...appConfig.get(),
+ workspace: appConfig.get().workspace,
+ 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);
+ }
+ if (onClose) {
+ onCloseSurvey();
+ }
+ } catch (error) {
+ logger.error(`Error handling WebView message: ${error as string}`);
+ }
+ }}
+ />
+ );
+
+ if (!isPassThrough) {
+ return (
+ {
+ setIsSurveyRunning(false);
+ // The same close as the survey's own X: it also clears the store, which the
+ // "already showing" guard in `triggerSurvey` reads. Leaving the survey there
+ // would block every later one until the app restarts.
+ onCloseSurvey();
+ }}
+ >
+
+
+ {webView}
+
+
+
+ );
+ }
+
+ // `overlay: none` renders in the host's own view tree instead of a . A Modal is its own
+ // native container and takes every touch however transparent it is, so it cannot let any
+ // through. Here the WebView sits in a view clipped to the card (see `getPassthroughFrames`), and
+ // a touch outside that clip reaches the host app.
+ if (!showSurvey) {
+ return null;
+ }
+
+ // The tree shape below never changes between states — only styles do. A different shape would
+ // remount the WebView, reload the survey and fire onDisplayCreated a second time.
+ const frames = getPassthroughFrames(cardState, hostSize);
+
+ // The bottom padding shrinks the measured host area, so the WebView shrinks with it and the
+ // renderer re-lays out the card above the keyboard — and reports the new rect, which moves the
+ // window with it. KeyboardAvoidingView supplies it on iOS; Android uses its own keyboard height.
+ // Android gets no `behavior`: "padding" always writes its own paddingBottom over ours.
+ //
+ // The outer view is what the Android padding is measured against, so it must never carry the
+ // padding itself; `collapsable={false}` stops Android flattening it away, which would leave
+ // `measureInWindow` nothing to measure.
+ return (
+
-
-
+
+ {
+ const { width, height } = event.nativeEvent.layout;
+ setHostSize({ width, height });
+ }}
>
- {
- if (isAllowedWebViewNavigation(event.url, appUrl)) {
- return true;
- }
-
- void openExternalUrl(event.url);
- return false;
- }}
- onMessage={(event: WebViewMessageEvent) => {
- try {
- const { data } = event.nativeEvent;
- const unvalidatedMessage = JSON.parse(data) as {
- type: string;
- data: unknown;
- };
-
- // debugger
- if (unvalidatedMessage.type === "Console") {
- if (__DEV__) {
- console.info(
- `[Console] ${JSON.stringify(unvalidatedMessage.data)}`,
- );
- }
- return;
- }
-
- const validatedMessage =
- ZJsRNWebViewOnMessageData.safeParse(unvalidatedMessage);
- if (!validatedMessage.success) {
- logger.error("Error parsing message from WebView.");
- return;
- }
-
- const {
- onClose,
- onDisplayCreated,
- onFinished,
- onOpenExternalURL,
- onOpenExternalURLParams,
- onResponseCreated,
- } = validatedMessage.data;
- if (onDisplayCreated) {
- const existingDisplays = appConfig.get().user.data.displays;
- const newDisplay = {
- surveyId: props.survey.id,
- createdAt: new Date(),
- };
-
- const displays = [...existingDisplays, newDisplay];
- const previousConfig = appConfig.get();
-
- const updatedUserState = {
- ...previousConfig.user,
- data: {
- ...previousConfig.user.data,
- displays,
- lastDisplayAt: new Date(),
- },
- };
-
- const filteredSurveys = filterSurveys(
- previousConfig.workspace,
- updatedUserState,
- );
-
- appConfig.update({
- ...previousConfig,
- workspace: previousConfig.workspace,
- 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;
- const newPersonState: TUserState = {
- ...appConfig.get().user,
- data: {
- ...appConfig.get().user.data,
- responses: [...responses, props.survey.id],
- },
- };
-
- const filteredSurveys = filterSurveys(
- appConfig.get().workspace,
- newPersonState,
- );
-
- appConfig.update({
- ...appConfig.get(),
- workspace: appConfig.get().workspace,
- 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);
- }
- if (onClose) {
- onCloseSurvey();
- }
- } catch (error) {
- logger.error(
- `Error handling WebView message: ${error as string}`,
- );
- }
- }}
- />
-
-
-
+
+ {webView}
+
+
+
+
);
}
+/**
+ * Back closes a no-overlay survey, as the Modal's `onRequestClose` does on the overlay path.
+ * Letting it reach the host instead could navigate away and leave the survey over another screen.
+ */
+function CloseOnBack({ onBack }: Readonly<{ onBack: () => void }>): null {
+ useEffect(() => {
+ const subscription = BackHandler.addEventListener(
+ "hardwareBackPress",
+ () => {
+ onBack();
+ return true;
+ },
+ );
+ return () => subscription.remove();
+ }, [onBack]);
+ return null;
+}
+
const isAllowedWebViewNavigation = (
candidateUrl: string,
appUrl: string,
@@ -445,6 +571,13 @@ export const renderHtml = (
window.ReactNativeWebView.postMessage(JSON.stringify({ onFinished: true }));
};
+ // Where the survey card is, so a no-overlay survey can let touches outside it through.
+ // Only newer renderers call this; against an older server it never fires and the survey
+ // keeps blocking the host as before.
+ function onCardRectChange(rect) {
+ window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'CardRect', data: rect }));
+ };
+
function getSetIsResponseSendingFinished() { /* noop — presence flips initial state to false so loading spinner renders until ResponseQueue resolves */ };
function getSetIsError() { /* noop */ };
@@ -456,6 +589,7 @@ export const renderHtml = (
onResponseCreated,
onFinished,
onClose,
+ onCardRectChange,
getSetIsResponseSendingFinished,
getSetIsError,
};
diff --git a/packages/react-native/src/components/tests/survey-touch-region.test.ts b/packages/react-native/src/components/tests/survey-touch-region.test.ts
new file mode 100644
index 0000000..37516ed
--- /dev/null
+++ b/packages/react-native/src/components/tests/survey-touch-region.test.ts
@@ -0,0 +1,100 @@
+import { describe, expect, test } from "vitest";
+import {
+ androidKeyboardPadding,
+ getPassthroughFrames,
+ parseCardRectMessage,
+} from "@/components/utils/survey-touch-region";
+
+/**
+ * A no-overlay survey lets touches through by clipping the WebView to the card. These pin what
+ * the clip covers in each state, because getting it wrong is invisible in review and obvious to a
+ * user: either the host app freezes, or the survey itself stops responding.
+ */
+describe("getPassthroughFrames", () => {
+ const host = { width: 400, height: 800 };
+ const card = { x: 16, y: 500, width: 368, height: 280 };
+ const fill = { position: "absolute", left: 0, top: 0, right: 0, bottom: 0 };
+
+ test("blocks everything until the renderer reports a rect", () => {
+ // An older server never reports one, and the survey must keep blocking as it always did.
+ const frames = getPassthroughFrames(undefined, host);
+
+ expect(frames.window).toEqual(fill);
+ expect(frames.webViewFrame).toEqual(fill);
+ });
+
+ test("keeps blocking until the host area is measured", () => {
+ expect(getPassthroughFrames(card, { width: 0, height: 0 }).window).toEqual(
+ fill,
+ );
+ });
+
+ test("clips to the card and keeps the WebView full-size behind it", () => {
+ const frames = getPassthroughFrames(card, host);
+
+ expect(frames.window).toEqual({
+ position: "absolute",
+ left: 16,
+ top: 500,
+ width: 368,
+ height: 280,
+ overflow: "hidden",
+ });
+ // Offset back so the card lands inside the window, at the host's full size so the renderer
+ // lays it out exactly as before.
+ expect(frames.webViewFrame).toEqual({
+ position: "absolute",
+ left: -16,
+ top: -500,
+ width: 400,
+ height: 800,
+ });
+ });
+
+ test("claims nothing once the card has gone", () => {
+ const frames = getPassthroughFrames(null, host);
+
+ expect(frames.window.width).toBe(0);
+ expect(frames.window.height).toBe(0);
+ expect(frames.window.overflow).toBe("hidden");
+ });
+});
+
+describe("parseCardRectMessage", () => {
+ test("reads a rect, and null as no card", () => {
+ expect(parseCardRectMessage({ x: 1, y: 2, width: 3, height: 4 })).toEqual({
+ x: 1,
+ y: 2,
+ width: 3,
+ height: 4,
+ });
+ expect(parseCardRectMessage(null)).toBeNull();
+ });
+
+ test("rejects malformed payloads rather than treating them as no card", () => {
+ // Reading junk as `null` would make the survey untappable.
+ expect(parseCardRectMessage({ x: "1", y: 2 })).toBeUndefined();
+ expect(parseCardRectMessage(undefined)).toBeUndefined();
+ });
+});
+
+describe("androidKeyboardPadding", () => {
+ test("an edge-to-edge host makes room for the whole keyboard", () => {
+ // The host still reaches the bottom of the screen, under the keyboard.
+ expect(androidKeyboardPadding(914, 578)).toBe(336);
+ });
+
+ test("a host whose window already shrank for the keyboard needs nothing more", () => {
+ // adjustResize without edge-to-edge: the host ends at the keyboard's top. Padding on top of
+ // that would push the card a whole keyboard height too far.
+ expect(androidKeyboardPadding(578, 578)).toBe(0);
+ });
+
+ test("only makes up the part of the host the keyboard covers", () => {
+ expect(androidKeyboardPadding(700, 578)).toBe(122);
+ });
+
+ test("is never negative", () => {
+ expect(androidKeyboardPadding(800, 914)).toBe(0);
+ });
+});
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
index 844a062..fd2a537 100644
--- 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
@@ -44,6 +44,21 @@ describe("WebView harness", () => {
expect(propsBlock).toContain("onClose,");
});
+ /** The card rect is how a no-overlay survey lets touches through; unwired, it silently blocks. */
+ test("wires onCardRectChange into renderSurvey's props and posts it back", () => {
+ const html = harness();
+ const propsBlock = html.slice(
+ html.indexOf("const surveyProps = {"),
+ html.indexOf("window.formbricksSurveys.renderSurvey"),
+ );
+
+ expect(html).toContain("function onCardRectChange(rect)");
+ expect(html).toContain(
+ "window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'CardRect', data: rect }))",
+ );
+ expect(propsBlock).toContain("onCardRectChange,");
+ });
+
/**
* 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
diff --git a/packages/react-native/src/components/utils/survey-touch-region.ts b/packages/react-native/src/components/utils/survey-touch-region.ts
new file mode 100644
index 0000000..253dd3d
--- /dev/null
+++ b/packages/react-native/src/components/utils/survey-touch-region.ts
@@ -0,0 +1,108 @@
+import { z } from "zod";
+
+/**
+ * Where the survey card is, as the renderer reports it through `onCardRectChange`. CSS pixels,
+ * which the viewport (`initial-scale=1.0`) makes equal to React Native points.
+ */
+export const ZCardRect = z.object({
+ x: z.number(),
+ y: z.number(),
+ width: z.number(),
+ height: z.number(),
+});
+
+export type TCardRect = z.infer;
+
+/**
+ * The card's state, in three values rather than two:
+ * - `undefined` — no report yet. An older self-hosted server serves a renderer that never calls
+ * `onCardRectChange`, and then the survey must keep blocking the host exactly as it always did.
+ * - a rect — only the card takes touches.
+ * - `null` — no card on screen (it is animating out), so nothing takes touches.
+ *
+ * Conflating the first and last is what once made the Flutter SDK's card untappable.
+ */
+export type TCardState = TCardRect | null | undefined;
+
+/** Reads a `CardRect` bridge message. Returns `undefined` for anything malformed, so junk changes nothing. */
+export const parseCardRectMessage = (
+ data: unknown,
+): TCardRect | null | undefined => {
+ if (data === null) return null;
+ const parsed = ZCardRect.safeParse(data);
+ return parsed.success ? parsed.data : undefined;
+};
+
+interface TAbsoluteFrame {
+ position: "absolute";
+ left: number;
+ top: number;
+ width?: number;
+ height?: number;
+ right?: number;
+ bottom?: number;
+ overflow?: "hidden";
+}
+
+const FILL: TAbsoluteFrame = {
+ position: "absolute",
+ left: 0,
+ top: 0,
+ right: 0,
+ bottom: 0,
+};
+
+/**
+ * Styles for the two views around the WebView on the no-overlay path.
+ *
+ * The WebView is never resized: the renderer caps the card's content at 60dvh of the WebView's
+ * own viewport, so shrinking the WebView to the card shrinks the card with it and the two settle
+ * at a clipped height. Instead a "window" view sits at the card's rect with `overflow: hidden`,
+ * holding the full-size WebView offset by -x/-y so only the card shows through. React Native
+ * hit-testing respects that clip, so a touch outside the window reaches the host app.
+ */
+export const getPassthroughFrames = (
+ card: TCardState,
+ hostSize: { width: number; height: number },
+): { window: TAbsoluteFrame; webViewFrame: TAbsoluteFrame } => {
+ // No report yet, or the host area is not measured: cover everything and block, as before.
+ if (card === undefined || hostSize.width === 0) {
+ return { window: FILL, webViewFrame: FILL };
+ }
+
+ // A null card collapses the window to nothing, so it claims no touches at all.
+ const rect = card ?? { x: 0, y: 0, width: 0, height: 0 };
+ return {
+ window: {
+ position: "absolute",
+ left: rect.x,
+ top: rect.y,
+ width: rect.width,
+ height: rect.height,
+ overflow: "hidden",
+ },
+ webViewFrame: {
+ position: "absolute",
+ left: -rect.x,
+ top: -rect.y,
+ width: hostSize.width,
+ height: hostSize.height,
+ },
+ };
+};
+
+/**
+ * Android only: how far the keyboard reaches up into the survey's host area, both edges in
+ * window coordinates.
+ *
+ * Measured against the host's own bottom edge, not the window's: an edge-to-edge app (Expo's
+ * default) does not shrink for the keyboard, so the host reaches the bottom and needs the whole
+ * keyboard; an app whose window still resizes (`adjustResize` without edge-to-edge) has already
+ * lost that height and needs nothing more. The keyboard's reported `height` leaves out the gesture
+ * bar under it, so its top comes from `screenY`. KeyboardAvoidingView is no help: it recomputes
+ * from the hide event too, whose `screenY` sits above the navigation bar, and leaves a gap.
+ */
+export const androidKeyboardPadding = (
+ hostBottom: number,
+ keyboardTop: number,
+): number => Math.max(0, hostBottom - keyboardTop);
diff --git a/packages/react-native/src/lib/survey/action.ts b/packages/react-native/src/lib/survey/action.ts
index 0266364..c0ce697 100644
--- a/packages/react-native/src/lib/survey/action.ts
+++ b/packages/react-native/src/lib/survey/action.ts
@@ -20,6 +20,16 @@ export const triggerSurvey = (survey: TSurvey): void => {
const surveyStore = SurveyStore.getInstance();
const logger = Logger.getInstance();
+ // A no-overlay survey leaves the host usable, so it can track again mid-survey. Without this
+ // a different survey would replace the open one in place.
+ const showing = surveyStore.getSurvey();
+ if (showing) {
+ logger.debug(
+ `Survey display of "${survey.id}" skipped: survey "${showing.id}" is already showing.`,
+ );
+ return;
+ }
+
// Check if the survey should be displayed based on displayPercentage
if (survey.displayPercentage) {
const shouldDisplaySurvey = shouldDisplayBasedOnPercentage(
diff --git a/packages/react-native/src/lib/survey/tests/action.test.ts b/packages/react-native/src/lib/survey/tests/action.test.ts
index 2477268..19d0ac3 100644
--- a/packages/react-native/src/lib/survey/tests/action.test.ts
+++ b/packages/react-native/src/lib/survey/tests/action.test.ts
@@ -59,6 +59,7 @@ describe("survey/action.ts", () => {
const mockSurveyStore = {
setSurvey: vi.fn(),
+ getSurvey: vi.fn(),
};
const mockLogger = {
@@ -110,6 +111,19 @@ describe("survey/action.ts", () => {
// Ensure survey is set
expect(mockSurveyStore.setSurvey).toHaveBeenCalledWith(mockSurvey);
});
+
+ // The host stays usable under a no-overlay survey, so it can track again mid-survey.
+ test("does not replace a survey that is already showing", () => {
+ vi.mocked(shouldDisplayBasedOnPercentage).mockReturnValueOnce(true);
+ mockSurveyStore.getSurvey.mockReturnValueOnce({ id: "survey_open" });
+
+ triggerSurvey(mockSurvey as unknown as TSurvey);
+
+ expect(mockSurveyStore.setSurvey).not.toHaveBeenCalled();
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ 'Survey display of "survey_001" skipped: survey "survey_open" is already showing.',
+ );
+ });
});
describe("trackAction", () => {