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
2 changes: 1 addition & 1 deletion apps/playground/app.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"image": "./assets/splash.png",
"resizeMode": "contain"
},
"userInterfaceStyle": "light",
"userInterfaceStyle": "automatic",
"version": "1.0.0",
"web": {
"favicon": "./assets/favicon.png"
Expand Down
45 changes: 42 additions & 3 deletions apps/playground/src/app.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import Formbricks, {
logout,
setAppearance,
setAttribute,
setAttributes,
setLanguage,
Expand All @@ -8,11 +9,20 @@ import Formbricks, {
} from "@formbricks/react-native";
import { StatusBar } from "expo-status-bar";
import type { JSX } from "react";
import { Button, LogBox, StyleSheet, Text, View } from "react-native";
import {
Appearance,
Button,
LogBox,
StyleSheet,
Text,
useColorScheme,
View,
} from "react-native";

LogBox.ignoreAllLogs();

export default function App(): JSX.Element {
const appTheme = useColorScheme();
if (!process.env.EXPO_PUBLIC_FORMBRICKS_WORKSPACE_ID) {
throw new Error("EXPO_PUBLIC_FORMBRICKS_WORKSPACE_ID is required");
}
Expand All @@ -22,8 +32,15 @@ export default function App(): JSX.Element {
}

return (
<View style={styles.container}>
<Text>Formbricks React Native SDK Demo</Text>
<View
style={[
styles.container,
{ backgroundColor: appTheme === "dark" ? "#000" : "#fff" },
]}
>
<Text style={{ color: appTheme === "dark" ? "#fff" : "#000" }}>
Formbricks React Native SDK Demo
</Text>

<View
style={{
Expand Down Expand Up @@ -79,6 +96,23 @@ export default function App(): JSX.Element {
}}
/>

<View style={styles.row}>
<Button title="Light" onPress={() => setAppearance("light")} />
<Button title="Dark" onPress={() => setAppearance("dark")} />
<Button title="System" onPress={() => setAppearance("system")} />
</View>

<View style={styles.row}>
<Button
title="App light"
onPress={() => Appearance.setColorScheme("light")}
/>
<Button
title="App dark"
onPress={() => Appearance.setColorScheme("dark")}
/>
</View>

<Button
title="Logout"
onPress={() => {
Expand Down Expand Up @@ -111,6 +145,11 @@ export default function App(): JSX.Element {
}

const styles = StyleSheet.create({
row: {
flexDirection: "row",
gap: 10,
justifyContent: "center",
},
container: {
flex: 1,
backgroundColor: "#fff",
Expand Down
18 changes: 18 additions & 0 deletions packages/react-native/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,3 +41,21 @@ Replace your-workspace-id with your actual workspace ID. You can find your works
> **Note:** The `environmentId` prop is still supported as a backward-compatible alias for `workspaceId`, but it is deprecated and will be removed in a future major release. New integrations should use `workspaceId`.

For more detailed guides for different frameworks, check out our [Framework Guides](https://formbricks.com/docs/getting-started/framework-guides).

## Dark mode

Surveys render light by default. Call `setAppearance` to change it:

```ts
import { setAppearance } from "@formbricks/react-native";

setAppearance("dark"); // "light" | "dark" | "system"
```

- Works before or after `setup()`, or pass `appearance` in the `setup()` config.
- An open survey switches in place; the typed answer and current question stay.
- `"system"` follows **your app's** theme (`Appearance.getColorScheme()`, including `Appearance.setColorScheme`), not the phone's, and updates live. If your app keeps its theme only in JS, call `setAppearance("light" | "dark")` yourself when it changes.
- Kept across `logout()`, forgotten on app restart, never sent to the server. An unknown value logs an error and falls back to light.
- Needs a Formbricks server that supports dark mode; an older server keeps surveys light.

Custom CSS configured in Formbricks needs no SDK call; it arrives with the workspace state.
69 changes: 67 additions & 2 deletions packages/react-native/src/components/survey-web-view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,28 @@ import {
View,
} from "react-native";
import { WebView, type WebViewMessageEvent } from "react-native-webview";
import { createAppearanceSync } from "@/components/utils/appearance-sync";
import { getSurveyScriptUrl } from "@/components/utils/survey-script-url";
import {
androidKeyboardPadding,
getPassthroughFrames,
parseCardRectMessage,
type TCardState,
} from "@/components/utils/survey-touch-region";
import {
getAppearance,
resolveAppearance,
type TResolvedAppearance,
watchResolvedAppearance,
} from "@/lib/common/appearance";
import { RNConfig } from "@/lib/common/config";
import { Logger } from "@/lib/common/logger";
import { filterSurveys, getLanguageCode, getStyling } from "@/lib/common/utils";
import {
filterSurveys,
getCustomCss,
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";
Expand All @@ -32,6 +44,18 @@ logger.configure({ logLevel: __DEV__ ? "debug" : "error" });

const surveyStore = SurveyStore.getInstance();

/** Switches the open survey's appearance; `undefined` means there is nothing to send. */
const injectAppearance = (
webView: WebView | null,
resolved: TResolvedAppearance | undefined,
): void => {
if (resolved === undefined) return;
// Optional chaining: an older server's renderer has no setAppearance and stays light.
webView?.injectJavaScript(
`window.formbricksSurveys?.setAppearance?.(${JSON.stringify(resolved)}); true;`,
);
};

interface SurveyWebViewProps {
readonly survey: TSurvey;
}
Expand All @@ -56,6 +80,13 @@ export function SurveyWebView(props: SurveyWebViewProps): JSX.Element | null {
*/
const [embeddedDataSnapshot, setEmbeddedDataSnapshot] =
useState<TIngestedFieldsRecord>({});
/**
* The appearance the survey opens with, frozen at display like the Embedded Data bag: it feeds
* `source`, and a change to `source` reloads the WebView and loses the respondent's answers. Later
* changes reach the open survey through `injectJavaScript` below instead.
*/
const [initialAppearance, setInitialAppearance] =
useState<TResolvedAppearance>("light");
/** The card's state as the renderer reports it; see `TCardState` for why it has three values. */
const [cardState, setCardState] = useState<TCardState>(undefined);
/** The area a no-overlay survey renders over, so the WebView keeps exactly that size. */
Expand Down Expand Up @@ -83,6 +114,21 @@ export function SurveyWebView(props: SurveyWebViewProps): JSX.Element | null {
};
}, []);

// Switches an open survey in place. The listeners live only while a survey is shown, so a closed
// survey holds none. The first callback repeats the value the survey opened with; skipped.
// Changes made before the renderer exists wait for its `onSurveyRendered` message (see
// `createAppearanceSync`), or they would be dropped and then deduplicated forever.
const appearanceSyncRef = useRef(createAppearanceSync());

useEffect(() => {
if (!showSurvey) return;
const appearanceSync = appearanceSyncRef.current;
appearanceSync.reset(initialAppearance); // the value baked into the HTML
return watchResolvedAppearance((resolved) => {
injectAppearance(webViewRef.current, appearanceSync.onChange(resolved));
});
}, [showSurvey, initialAppearance]);

useEffect(() => {
const fetchConfig = async (): Promise<void> => {
const config = await RNConfig.getInstance();
Expand Down Expand Up @@ -135,6 +181,7 @@ export function SurveyWebView(props: SurveyWebViewProps): JSX.Element | null {
*/
const display = (): void => {
setEmbeddedDataSnapshot(EmbeddedDataStore.getInstance().getSnapshot());
setInitialAppearance(resolveAppearance(getAppearance()));
setShowSurvey(true);
};

Expand Down Expand Up @@ -202,6 +249,8 @@ export function SurveyWebView(props: SurveyWebViewProps): JSX.Element | null {
clickOutside,
overlay,
isWebEnvironment: false,
appearance: initialAppearance,
Comment thread
Dhruwang marked this conversation as resolved.
customCss: getCustomCss(settings, props.survey),
// 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
Expand All @@ -213,7 +262,8 @@ export function SurveyWebView(props: SurveyWebViewProps): JSX.Element | null {
contentMode="mobile"
javaScriptEnabled
domStorageEnabled
startInLoadingState
// No `startInLoadingState`: its default loader is an opaque white full-screen view, which
// flashes over dark apps. The transparent WebView shows nothing until the card paints.
scrollEnabled={false}
setSupportMultipleWindows={false}
onShouldStartLoadWithRequest={(event) => {
Expand All @@ -232,6 +282,20 @@ export function SurveyWebView(props: SurveyWebViewProps): JSX.Element | null {
data: unknown;
};

// Handled before the strict schema below, which would reject this message.
if (
(unvalidatedMessage as { onSurveyRendered?: unknown })
.onSurveyRendered === true
) {
injectAppearance(
webViewRef.current,
appearanceSyncRef.current.onRendered(
resolveAppearance(getAppearance()),
),
);
return;
}

if (unvalidatedMessage.type === "CardRect") {
const card = parseCardRectMessage(unvalidatedMessage.data);
if (card === undefined) {
Expand Down Expand Up @@ -595,6 +659,7 @@ export const renderHtml = (
};

window.formbricksSurveys.renderSurvey(surveyProps);
window.ReactNativeWebView.postMessage(JSON.stringify({ onSurveyRendered: true }));
}

const script = document.createElement("script");
Expand Down
51 changes: 51 additions & 0 deletions packages/react-native/src/components/tests/appearance-sync.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, test } from "vitest";
import { createAppearanceSync } from "@/components/utils/appearance-sync";

describe("createAppearanceSync", () => {
test("sends nothing before the survey has rendered", () => {
const sync = createAppearanceSync();
sync.reset("light");

expect(sync.onChange("dark")).toBeUndefined();
expect(sync.onChange("dark")).toBeUndefined();
});

/** The bug: a change made while the bundle loads was dropped, and later repeats deduplicated. */
test("sends the latest value on render when it changed while loading", () => {
const sync = createAppearanceSync();
sync.reset("light");
sync.onChange("dark");

expect(sync.onRendered("dark")).toBe("dark");
// Recorded as applied, so a repeat is a no-op.
expect(sync.onChange("dark")).toBeUndefined();
});

test("sends nothing on render when the value matches the one baked into the HTML", () => {
const sync = createAppearanceSync();
sync.reset("dark");

expect(sync.onRendered("dark")).toBeUndefined();
});

test("sends only real changes once rendered", () => {
const sync = createAppearanceSync();
sync.reset("light");
sync.onRendered("light");

expect(sync.onChange("light")).toBeUndefined();
expect(sync.onChange("dark")).toBe("dark");
expect(sync.onChange("dark")).toBeUndefined();
expect(sync.onChange("light")).toBe("light");
});

test("reset for a new survey waits for that survey to render again", () => {
const sync = createAppearanceSync();
sync.reset("light");
sync.onRendered("light");
sync.reset("dark");

expect(sync.onChange("light")).toBeUndefined();
expect(sync.onRendered("light")).toBe("light");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,20 @@ describe("WebView harness", () => {
expect(propsBlock).toContain("onClose,");
});

/** The host holds appearance changes until this message; without it they never reach the survey. */
test("posts onSurveyRendered right after renderSurvey", () => {
const html = harness();
const renderAt = html.indexOf(
"window.formbricksSurveys.renderSurvey(surveyProps);",
);
const postAt = html.indexOf(
"window.ReactNativeWebView.postMessage(JSON.stringify({ onSurveyRendered: true }))",
);

expect(renderAt).toBeGreaterThan(-1);
expect(postAt).toBeGreaterThan(renderAt);
});

/** 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();
Expand Down Expand Up @@ -89,4 +103,21 @@ describe("WebView harness", () => {
// Each "<" is emitted as the literal six-character sequence \u003c.
expect(html).toContain("\\u003c/script>");
});

test("hands appearance and customCss to renderSurvey as plain options", () => {
const html = renderHtml({
appUrl: "https://app.formbricks.com",
workspaceId: "ws-1",
appearance: "dark",
customCss: { workspace: { dark: ".a{color:red}" } },
});
expect(html).toContain('"appearance":"dark"');
expect(html).toContain(
'"customCss":{"workspace":{"dark":".a{color:red}"}}',
);
});

test("sends no customCss key when there is none", () => {
expect(harness()).not.toContain("customCss");
});
});
43 changes: 43 additions & 0 deletions packages/react-native/src/components/utils/appearance-sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
import type { TResolvedAppearance } from "@/lib/common/appearance";

/**
* Decides when an open survey's appearance has to be sent to the renderer.
*
* Until the renderer reports `onSurveyRendered` there is no `formbricksSurveys.setAppearance` to
* call, so an injection would be a no-op. Changes made meanwhile therefore wait, and the rendered
* handshake sends the latest one. Each method returns the value to inject, or `undefined` when
* nothing needs sending; a returned value is recorded as applied.
*/
export interface TAppearanceSync {
/** A survey is shown with `initial` baked into its HTML; it has not rendered yet. */
reset: (initial: TResolvedAppearance) => void;
/** The resolved appearance changed while the survey is shown. */
onChange: (resolved: TResolvedAppearance) => TResolvedAppearance | undefined;
/** The renderer exists; `current` is the appearance resolved right now. */
onRendered: (current: TResolvedAppearance) => TResolvedAppearance | undefined;
}

export const createAppearanceSync = (): TAppearanceSync => {
let rendered = false;
let applied: TResolvedAppearance = "light";

const sendIfChanged = (
resolved: TResolvedAppearance,
): TResolvedAppearance | undefined => {
if (resolved === applied) return undefined;
applied = resolved;
return resolved;
};

return {
reset: (initial) => {
rendered = false;
applied = initial;
},
onChange: (resolved) => (rendered ? sendIfChanged(resolved) : undefined),
onRendered: (current) => {
rendered = true;
return sendIfChanged(current);
},
};
};
Loading
Loading