diff --git a/apps/demo-react-native/.env.example b/apps/demo-react-native/.env.example
index 3a2d97bdc4..340aecb341 100644
--- a/apps/demo-react-native/.env.example
+++ b/apps/demo-react-native/.env.example
@@ -1,2 +1,2 @@
-EXPO_PUBLIC_API_HOST=http://192.168.178.20:3000
-EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=clzr04nkd000bcdl110j0ijyq
+EXPO_PUBLIC_APP_URL=http://192.168.0.197:3000
+EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID=cm5p0cs7r000819182b32j0a1
\ No newline at end of file
diff --git a/apps/demo-react-native/app.json b/apps/demo-react-native/app.json
index 66cd17cbb8..31d6cb2a53 100644
--- a/apps/demo-react-native/app.json
+++ b/apps/demo-react-native/app.json
@@ -18,6 +18,7 @@
},
"jsEngine": "hermes",
"name": "react-native-demo",
+ "newArchEnabled": true,
"orientation": "portrait",
"slug": "react-native-demo",
"splash": {
diff --git a/apps/demo-react-native/package.json b/apps/demo-react-native/package.json
index 4dc5955136..acd06c3451 100644
--- a/apps/demo-react-native/package.json
+++ b/apps/demo-react-native/package.json
@@ -13,16 +13,17 @@
"dependencies": {
"@formbricks/js": "workspace:*",
"@formbricks/react-native": "workspace:*",
- "expo": "52.0.18",
- "expo-status-bar": "2.0.0",
+ "@react-native-async-storage/async-storage": "2.1.0",
+ "expo": "52.0.28",
+ "expo-status-bar": "2.0.1",
"react": "18.3.1",
"react-dom": "18.3.1",
- "react-native": "0.76.5",
+ "react-native": "0.76.6",
"react-native-webview": "13.12.5"
},
"devDependencies": {
"@babel/core": "7.26.0",
- "@types/react": "19.0.1",
+ "@types/react": "18.3.18",
"typescript": "5.7.2"
},
"private": true
diff --git a/apps/demo-react-native/src/app.tsx b/apps/demo-react-native/src/app.tsx
index 28e0f50552..a4816481e3 100644
--- a/apps/demo-react-native/src/app.tsx
+++ b/apps/demo-react-native/src/app.tsx
@@ -1,7 +1,14 @@
import { StatusBar } from "expo-status-bar";
import React, { type JSX } from "react";
import { Button, LogBox, StyleSheet, Text, View } from "react-native";
-import Formbricks, { track } from "@formbricks/react-native";
+import Formbricks, {
+ logout,
+ setAttribute,
+ setAttributes,
+ setLanguage,
+ setUserId,
+ track,
+} from "@formbricks/react-native";
LogBox.ignoreAllLogs();
@@ -10,35 +17,92 @@ export default function App(): JSX.Element {
throw new Error("EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID is required");
}
- if (!process.env.EXPO_PUBLIC_API_HOST) {
- throw new Error("EXPO_PUBLIC_API_HOST is required");
+ if (!process.env.EXPO_PUBLIC_APP_URL) {
+ throw new Error("EXPO_PUBLIC_APP_URL is required");
}
- const config = {
- environmentId: process.env.EXPO_PUBLIC_FORMBRICKS_ENVIRONMENT_ID as string,
- apiHost: process.env.EXPO_PUBLIC_API_HOST as string,
- userId: "random-user-id",
- attributes: {
- language: "en",
- testAttr: "attr-test",
- },
- };
-
return (
Formbricks React Native SDK Demo
-
);
}
diff --git a/apps/docs/app/app-surveys/framework-guides/page.mdx b/apps/docs/app/app-surveys/framework-guides/page.mdx
index deca5bbcf8..85d27a588c 100644
--- a/apps/docs/app/app-surveys/framework-guides/page.mdx
+++ b/apps/docs/app/app-surveys/framework-guides/page.mdx
@@ -357,17 +357,11 @@ Now, update your App.js/App.tsx file to initialize Formbricks:
// other imports
import Formbricks from "@formbricks/react-native";
-const config = {
- environmentId: "",
- apiHost: "",
- userId: "", // optional
-};
-
export default function App() {
return (
<>
{/* Your app content */}
-
+
>
);
}
@@ -381,7 +375,7 @@ export default function App() {
Formbricks Environment ID.
-
+
URL of the hosted Formbricks instance.
diff --git a/apps/web/app/api/v1/client/[environmentId]/environment/lib/environmentState.ts b/apps/web/app/api/v1/client/[environmentId]/environment/lib/environmentState.ts
index 76c771f610..51d2dd0d4c 100644
--- a/apps/web/app/api/v1/client/[environmentId]/environment/lib/environmentState.ts
+++ b/apps/web/app/api/v1/client/[environmentId]/environment/lib/environmentState.ts
@@ -29,7 +29,7 @@ import { getSurveysForEnvironmentState } from "./survey";
*/
export const getEnvironmentState = async (
environmentId: string
-): Promise<{ state: TJsEnvironmentState["data"]; revalidateEnvironment?: boolean }> =>
+): Promise<{ data: TJsEnvironmentState["data"]; revalidateEnvironment?: boolean }> =>
cache(
async () => {
let revalidateEnvironment = false;
@@ -102,14 +102,14 @@ export const getEnvironmentState = async (
(survey) => survey.type === "app" && survey.status === "inProgress"
);
- const state: TJsEnvironmentState["data"] = {
+ const data: TJsEnvironmentState["data"] = {
surveys: !isMonthlyResponsesLimitReached ? filteredSurveys : [],
actionClasses,
project: project,
};
return {
- state,
+ data,
revalidateEnvironment,
};
},
diff --git a/apps/web/app/api/v1/client/[environmentId]/environment/route.ts b/apps/web/app/api/v1/client/[environmentId]/environment/route.ts
index c24b3f1f5d..a57d36109e 100644
--- a/apps/web/app/api/v1/client/[environmentId]/environment/route.ts
+++ b/apps/web/app/api/v1/client/[environmentId]/environment/route.ts
@@ -36,16 +36,20 @@ export const GET = async (
try {
const environmentState = await getEnvironmentState(params.environmentId);
+ const { data, revalidateEnvironment } = environmentState;
- if (environmentState.revalidateEnvironment) {
+ if (revalidateEnvironment) {
environmentCache.revalidate({
id: inputValidation.data.environmentId,
- projectId: environmentState.state.project.id,
+ projectId: data.project.id,
});
}
return responses.successResponse(
- environmentState.state,
+ {
+ data,
+ expiresAt: new Date(Date.now() + 1000 * 60 * 30), // 30 minutes
+ },
true,
"public, s-maxage=600, max-age=840, stale-while-revalidate=600, stale-if-error=600"
);
diff --git a/apps/web/app/api/v1/client/[environmentId]/user/route.ts b/apps/web/app/api/v1/client/[environmentId]/user/route.ts
new file mode 100644
index 0000000000..0198ac1f99
--- /dev/null
+++ b/apps/web/app/api/v1/client/[environmentId]/user/route.ts
@@ -0,0 +1,3 @@
+import { OPTIONS, POST } from "@/modules/ee/contacts/api/client/[environmentId]/user/route";
+
+export { POST, OPTIONS };
diff --git a/apps/web/modules/ee/billing/components/pricing-table.tsx b/apps/web/modules/ee/billing/components/pricing-table.tsx
index 748a4cfb52..3e2eac2b6a 100644
--- a/apps/web/modules/ee/billing/components/pricing-table.tsx
+++ b/apps/web/modules/ee/billing/components/pricing-table.tsx
@@ -102,7 +102,6 @@ export const PricingTable = ({
throw new Error(t("common.something_went_wrong_please_try_again"));
}
} catch (err) {
- console.log({ err });
toast.error(t("environments.settings.billing.unable_to_upgrade_plan"));
}
};
diff --git a/apps/web/modules/ee/contacts/actions.ts b/apps/web/modules/ee/contacts/actions.ts
index d6e7322aad..2ff6d85d0c 100644
--- a/apps/web/modules/ee/contacts/actions.ts
+++ b/apps/web/modules/ee/contacts/actions.ts
@@ -45,12 +45,12 @@ export const getContactsAction = authenticatedActionClient
return getContacts(parsedInput.environmentId, parsedInput.offset, parsedInput.searchValue);
});
-const ZPersonDeleteAction = z.object({
+const ZContactDeleteAction = z.object({
contactId: ZId,
});
export const deleteContactAction = authenticatedActionClient
- .schema(ZPersonDeleteAction)
+ .schema(ZContactDeleteAction)
.action(async ({ ctx, parsedInput }) => {
const organizationId = await getOrganizationIdFromContactId(parsedInput.contactId);
const projectId = await getProjectIdFromContactId(parsedInput.contactId);
diff --git a/apps/web/modules/ee/contacts/api/client/[environmentId]/contacts/[userId]/attributes/lib/contact.ts b/apps/web/modules/ee/contacts/api/client/[environmentId]/contacts/[userId]/attributes/lib/contact.ts
index 70a9c46384..324a73701b 100644
--- a/apps/web/modules/ee/contacts/api/client/[environmentId]/contacts/[userId]/attributes/lib/contact.ts
+++ b/apps/web/modules/ee/contacts/api/client/[environmentId]/contacts/[userId]/attributes/lib/contact.ts
@@ -35,7 +35,7 @@ export const getContactByUserIdWithAttributes = reactCache(
return contact;
},
- [`getContactByUserId-${environmentId}-${userId}-${JSON.stringify(updatedAttributes)}`],
+ [`getContactByUserIdWithAttributes-${environmentId}-${userId}-${JSON.stringify(updatedAttributes)}`],
{
tags: [
contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId),
diff --git a/apps/web/modules/ee/contacts/api/client/[environmentId]/contacts/[userId]/attributes/route.ts b/apps/web/modules/ee/contacts/api/client/[environmentId]/contacts/[userId]/attributes/route.ts
index b526dfd89b..a71891c8d3 100644
--- a/apps/web/modules/ee/contacts/api/client/[environmentId]/contacts/[userId]/attributes/route.ts
+++ b/apps/web/modules/ee/contacts/api/client/[environmentId]/contacts/[userId]/attributes/route.ts
@@ -1,10 +1,10 @@
import { responses } from "@/app/lib/api/response";
import { transformErrorToDetails } from "@/app/lib/api/validator";
+import { updateAttributes } from "@/modules/ee/contacts/lib/attributes";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { NextRequest } from "next/server";
import { ResourceNotFoundError } from "@formbricks/types/errors";
import { ZJsContactsUpdateAttributeInput } from "@formbricks/types/js";
-import { updateAttributes } from "./lib/attributes";
import { getContactByUserIdWithAttributes } from "./lib/contact";
export const OPTIONS = async () => {
@@ -74,36 +74,15 @@ export const PUT = async (
);
}
- const { details: updateAttrDetails } = await updateAttributes(
- contact.id,
- userId,
- environmentId,
- updatedAttributes
- );
-
- // if userIdAttr or idAttr was in the payload, we need to inform the user that it was ignored
- const details: Record = {};
- if (userIdAttr) {
- details.userId = "updating userId is ignored as it is a reserved field and cannot be updated.";
- }
-
- if (idAttr) {
- details.id = "updating id is ignored as it is a reserved field and cannot be updated.";
- }
-
- if (updateAttrDetails && Object.keys(updateAttrDetails).length > 0) {
- Object.entries(updateAttrDetails).forEach(([key, value]) => {
- details[key] = value;
- });
- }
+ const { messages } = await updateAttributes(contact.id, userId, environmentId, updatedAttributes);
return responses.successResponse(
{
changed: true,
message: "The person was successfully updated.",
- ...(Object.keys(details).length > 0
+ ...(messages && messages.length > 0
? {
- details,
+ messages,
}
: {}),
},
diff --git a/apps/web/modules/ee/contacts/api/client/[environmentId]/identify/contacts/[userId]/route.ts b/apps/web/modules/ee/contacts/api/client/[environmentId]/identify/contacts/[userId]/route.ts
index e3f935a1f4..ea0bfaf3e2 100644
--- a/apps/web/modules/ee/contacts/api/client/[environmentId]/identify/contacts/[userId]/route.ts
+++ b/apps/web/modules/ee/contacts/api/client/[environmentId]/identify/contacts/[userId]/route.ts
@@ -4,7 +4,7 @@ import { contactCache } from "@/lib/cache/contact";
import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
import { NextRequest, userAgent } from "next/server";
import { ResourceNotFoundError } from "@formbricks/types/errors";
-import { ZJsPersonIdentifyInput } from "@formbricks/types/js";
+import { ZJsUserIdentifyInput } from "@formbricks/types/js";
import { getPersonState } from "./lib/personState";
export const OPTIONS = async (): Promise => {
@@ -21,7 +21,7 @@ export const GET = async (
const { environmentId, userId } = params;
// Validate input
- const syncInputValidation = ZJsPersonIdentifyInput.safeParse({
+ const syncInputValidation = ZJsUserIdentifyInput.safeParse({
environmentId,
userId,
});
diff --git a/apps/web/modules/ee/contacts/api/client/[environmentId]/user/lib/contact.ts b/apps/web/modules/ee/contacts/api/client/[environmentId]/user/lib/contact.ts
new file mode 100644
index 0000000000..45d8af47c6
--- /dev/null
+++ b/apps/web/modules/ee/contacts/api/client/[environmentId]/user/lib/contact.ts
@@ -0,0 +1,39 @@
+import { contactCache } from "@/lib/cache/contact";
+import { contactAttributeCache } from "@/lib/cache/contact-attribute";
+import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
+import { cache as reactCache } from "react";
+import { prisma } from "@formbricks/database";
+import { cache } from "@formbricks/lib/cache";
+
+export const getContactByUserIdWithAttributes = reactCache((environmentId: string, userId: string) =>
+ cache(
+ async () => {
+ const contact = await prisma.contact.findFirst({
+ where: {
+ environmentId,
+ attributes: { some: { attributeKey: { key: "userId", environmentId }, value: userId } },
+ },
+ select: {
+ id: true,
+ attributes: {
+ select: { attributeKey: { select: { key: true } }, value: true },
+ },
+ },
+ });
+
+ if (!contact) {
+ return null;
+ }
+
+ return contact;
+ },
+ [`getContactByUserIdWithAttributes-${environmentId}-${userId}`],
+ {
+ tags: [
+ contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId),
+ contactAttributeCache.tag.byEnvironmentIdAndUserId(environmentId, userId),
+ contactAttributeKeyCache.tag.byEnvironmentId(environmentId),
+ ],
+ }
+ )()
+);
diff --git a/apps/web/modules/ee/contacts/api/client/[environmentId]/user/lib/segments.ts b/apps/web/modules/ee/contacts/api/client/[environmentId]/user/lib/segments.ts
new file mode 100644
index 0000000000..7405244066
--- /dev/null
+++ b/apps/web/modules/ee/contacts/api/client/[environmentId]/user/lib/segments.ts
@@ -0,0 +1,82 @@
+import { contactAttributeCache } from "@/lib/cache/contact-attribute";
+import { evaluateSegment } from "@/modules/ee/contacts/segments/lib/segments";
+import { Prisma } from "@prisma/client";
+import { cache as reactCache } from "react";
+import { prisma } from "@formbricks/database";
+import { cache } from "@formbricks/lib/cache";
+import { segmentCache } from "@formbricks/lib/cache/segment";
+import { validateInputs } from "@formbricks/lib/utils/validate";
+import { ZId, ZString } from "@formbricks/types/common";
+import { DatabaseError } from "@formbricks/types/errors";
+import { TBaseFilter } from "@formbricks/types/segment";
+
+const getSegments = reactCache((environmentId: string) =>
+ cache(
+ async () => {
+ try {
+ return prisma.segment.findMany({
+ where: { environmentId },
+ select: { id: true, filters: true },
+ });
+ } catch (error) {
+ if (error instanceof Prisma.PrismaClientKnownRequestError) {
+ throw new DatabaseError(error.message);
+ }
+
+ throw error;
+ }
+ },
+ [`getSegments-environmentId-${environmentId}`],
+ {
+ tags: [segmentCache.tag.byEnvironmentId(environmentId)],
+ }
+ )()
+);
+
+export const getPersonSegmentIds = (
+ environmentId: string,
+ contactId: string,
+ contactUserId: string,
+ attributes: Record,
+ deviceType: "phone" | "desktop"
+): Promise =>
+ cache(
+ async () => {
+ validateInputs([environmentId, ZId], [contactId, ZId], [contactUserId, ZString]);
+
+ const segments = await getSegments(environmentId);
+
+ // fast path; if there are no segments, return an empty array
+ if (!segments) {
+ return [];
+ }
+
+ const personSegments: { id: string; filters: TBaseFilter[] }[] = [];
+
+ for (const segment of segments) {
+ const isIncluded = await evaluateSegment(
+ {
+ attributes,
+ deviceType,
+ environmentId,
+ contactId: contactId,
+ userId: contactUserId,
+ },
+ segment.filters
+ );
+
+ if (isIncluded) {
+ personSegments.push(segment);
+ }
+ }
+
+ return personSegments.map((segment) => segment.id);
+ },
+ [`getPersonSegmentIds-${environmentId}-${contactId}-${deviceType}`],
+ {
+ tags: [
+ segmentCache.tag.byEnvironmentId(environmentId),
+ contactAttributeCache.tag.byContactId(contactId),
+ ],
+ }
+ )();
diff --git a/apps/web/modules/ee/contacts/api/client/[environmentId]/user/lib/update-user.ts b/apps/web/modules/ee/contacts/api/client/[environmentId]/user/lib/update-user.ts
new file mode 100644
index 0000000000..13345b92f1
--- /dev/null
+++ b/apps/web/modules/ee/contacts/api/client/[environmentId]/user/lib/update-user.ts
@@ -0,0 +1,129 @@
+import { contactCache } from "@/lib/cache/contact";
+import { updateAttributes } from "@/modules/ee/contacts/lib/attributes";
+import { prisma } from "@formbricks/database";
+import { getEnvironment } from "@formbricks/lib/environment/service";
+import { ResourceNotFoundError } from "@formbricks/types/errors";
+import { TJsPersonState } from "@formbricks/types/js";
+import { getContactByUserIdWithAttributes } from "./contact";
+import { getUserState } from "./user-state";
+
+export const updateUser = async (
+ environmentId: string,
+ userId: string,
+ device: "phone" | "desktop",
+ attributes?: Record
+): Promise<{ state: TJsPersonState; messages?: string[] }> => {
+ const environment = await getEnvironment(environmentId);
+
+ if (!environment) {
+ throw new ResourceNotFoundError(`environment`, environmentId);
+ }
+
+ let contact = await getContactByUserIdWithAttributes(environmentId, userId);
+
+ if (!contact) {
+ contact = await prisma.contact.create({
+ data: {
+ environment: {
+ connect: {
+ id: environmentId,
+ },
+ },
+ attributes: {
+ create: [
+ {
+ attributeKey: {
+ connect: { key_environmentId: { key: "userId", environmentId } },
+ },
+ value: userId,
+ },
+ ],
+ },
+ },
+ select: {
+ id: true,
+ attributes: {
+ select: { attributeKey: { select: { key: true } }, value: true },
+ },
+ },
+ });
+
+ contactCache.revalidate({
+ environmentId,
+ userId,
+ id: contact.id,
+ });
+ }
+
+ let contactAttributes = contact.attributes.reduce(
+ (acc, ctx) => {
+ acc[ctx.attributeKey.key] = ctx.value;
+ return acc;
+ },
+ {} as Record
+ );
+
+ // update the contact attributes if needed:
+ let messages: string[] = [];
+ let language = contactAttributes.language;
+
+ if (attributes && Object.keys(attributes).length > 0) {
+ let shouldUpdate = false;
+ const oldAttributes = contact.attributes.reduce(
+ (acc, ctx) => {
+ acc[ctx.attributeKey.key] = ctx.value;
+ return acc;
+ },
+ {} as Record
+ );
+
+ for (const [key, value] of Object.entries(attributes)) {
+ if (value !== oldAttributes[key]) {
+ shouldUpdate = true;
+ break;
+ }
+ }
+
+ if (shouldUpdate) {
+ const { success, messages: updateAttrMessages } = await updateAttributes(
+ contact.id,
+ userId,
+ environmentId,
+ attributes
+ );
+
+ messages = updateAttrMessages ?? [];
+
+ // If the attributes update was successful and the language attribute was provided, set the language
+ if (success) {
+ contactAttributes = {
+ ...contactAttributes,
+ ...attributes,
+ };
+
+ if (attributes.language) {
+ language = attributes.language;
+ }
+ }
+ }
+ }
+
+ const userState = await getUserState({
+ environmentId,
+ userId,
+ contactId: contact.id,
+ attributes: contactAttributes,
+ device,
+ });
+
+ return {
+ state: {
+ data: {
+ ...userState,
+ language,
+ },
+ expiresAt: new Date(Date.now() + 1000 * 60 * 30), // 30 minutes
+ },
+ messages,
+ };
+};
diff --git a/apps/web/modules/ee/contacts/api/client/[environmentId]/user/lib/user-state.ts b/apps/web/modules/ee/contacts/api/client/[environmentId]/user/lib/user-state.ts
new file mode 100644
index 0000000000..df7b5e8c5c
--- /dev/null
+++ b/apps/web/modules/ee/contacts/api/client/[environmentId]/user/lib/user-state.ts
@@ -0,0 +1,89 @@
+import { contactCache } from "@/lib/cache/contact";
+import { contactAttributeCache } from "@/lib/cache/contact-attribute";
+import { prisma } from "@formbricks/database";
+import { cache } from "@formbricks/lib/cache";
+import { segmentCache } from "@formbricks/lib/cache/segment";
+import { displayCache } from "@formbricks/lib/display/cache";
+import { environmentCache } from "@formbricks/lib/environment/cache";
+import { organizationCache } from "@formbricks/lib/organization/cache";
+import { responseCache } from "@formbricks/lib/response/cache";
+import { TJsPersonState } from "@formbricks/types/js";
+import { getPersonSegmentIds } from "./segments";
+
+/**
+ *
+ * @param environmentId - The environment id
+ * @param userId - The user id
+ * @param device - The device type
+ * @param attributes - The contact attributes
+ * @returns The person state
+ * @throws {ValidationError} - If the input is invalid
+ * @throws {ResourceNotFoundError} - If the environment or organization is not found
+ */
+export const getUserState = async ({
+ environmentId,
+ userId,
+ contactId,
+ device,
+ attributes,
+}: {
+ environmentId: string;
+ userId: string;
+ contactId: string;
+ device: "phone" | "desktop";
+ attributes: Record;
+}): Promise =>
+ cache(
+ async () => {
+ const contactResponses = await prisma.response.findMany({
+ where: {
+ contactId,
+ },
+ select: {
+ surveyId: true,
+ },
+ });
+
+ const contactDisplays = await prisma.display.findMany({
+ where: {
+ contactId,
+ },
+ select: {
+ surveyId: true,
+ createdAt: true,
+ },
+ });
+
+ const segments = await getPersonSegmentIds(environmentId, contactId, userId, attributes, device);
+
+ // If the person exists, return the persons's state
+ const userState: TJsPersonState["data"] = {
+ userId,
+ segments,
+ displays:
+ contactDisplays?.map((display) => ({
+ surveyId: display.surveyId,
+ createdAt: display.createdAt,
+ })) ?? [],
+ responses: contactResponses?.map((response) => response.surveyId) ?? [],
+ lastDisplayAt:
+ contactDisplays.length > 0
+ ? contactDisplays.sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime())[0].createdAt
+ : null,
+ };
+
+ return userState;
+ },
+ [`personState-${environmentId}-${userId}-${device}`],
+ {
+ tags: [
+ environmentCache.tag.byId(environmentId),
+ organizationCache.tag.byEnvironmentId(environmentId),
+ contactCache.tag.byEnvironmentIdAndUserId(environmentId, userId),
+ contactAttributeCache.tag.byEnvironmentIdAndUserId(environmentId, userId),
+ displayCache.tag.byEnvironmentIdAndUserId(environmentId, userId),
+ responseCache.tag.byEnvironmentIdAndUserId(environmentId, userId),
+ segmentCache.tag.byEnvironmentId(environmentId),
+ ],
+ }
+ )();
diff --git a/apps/web/modules/ee/contacts/api/client/[environmentId]/user/route.ts b/apps/web/modules/ee/contacts/api/client/[environmentId]/user/route.ts
new file mode 100644
index 0000000000..f1023d61e3
--- /dev/null
+++ b/apps/web/modules/ee/contacts/api/client/[environmentId]/user/route.ts
@@ -0,0 +1,92 @@
+import { responses } from "@/app/lib/api/response";
+import { transformErrorToDetails } from "@/app/lib/api/validator";
+import { getIsContactsEnabled } from "@/modules/ee/license-check/lib/utils";
+import { NextRequest, userAgent } from "next/server";
+import { TContactAttributes } from "@formbricks/types/contact-attribute";
+import { ResourceNotFoundError } from "@formbricks/types/errors";
+import { TJsPersonState, ZJsUserIdentifyInput, ZJsUserUpdateInput } from "@formbricks/types/js";
+import { updateUser } from "./lib/update-user";
+
+export const OPTIONS = async (): Promise => {
+ return responses.successResponse({}, true);
+};
+
+export const POST = async (
+ request: NextRequest,
+ props: { params: Promise<{ environmentId: string }> }
+): Promise => {
+ const params = await props.params;
+
+ try {
+ const { environmentId } = params;
+ const jsonInput = await request.json();
+
+ // Validate input
+ const syncInputValidation = ZJsUserIdentifyInput.pick({ environmentId: true }).safeParse({
+ environmentId,
+ });
+
+ if (!syncInputValidation.success) {
+ return responses.badRequestResponse(
+ "Fields are missing or incorrectly formatted",
+ transformErrorToDetails(syncInputValidation.error),
+ true
+ );
+ }
+
+ const parsedInput = ZJsUserUpdateInput.safeParse(jsonInput);
+ if (!parsedInput.success) {
+ return responses.badRequestResponse(
+ "Fields are missing or incorrectly formatted",
+ transformErrorToDetails(parsedInput.error),
+ true
+ );
+ }
+
+ const { userId, attributes } = parsedInput.data;
+
+ const isContactsEnabled = await getIsContactsEnabled();
+ if (!isContactsEnabled) {
+ return responses.forbiddenResponse("User identification is only available for enterprise users.", true);
+ }
+
+ let attributeUpdatesToSend: TContactAttributes | null = null;
+ if (attributes) {
+ // remove userId and id from attributes
+ const { userId: userIdAttr, id: idAttr, ...updatedAttributes } = attributes;
+ attributeUpdatesToSend = updatedAttributes;
+ }
+
+ const { device } = userAgent(request);
+ const deviceType = device ? "phone" : "desktop";
+
+ try {
+ const { state: userState, messages } = await updateUser(
+ environmentId,
+ userId,
+ deviceType,
+ attributeUpdatesToSend ?? undefined
+ );
+
+ let responseJson: { state: TJsPersonState; messages?: string[] } = {
+ state: userState,
+ };
+
+ if (messages && messages.length > 0) {
+ responseJson.messages = messages;
+ }
+
+ return responses.successResponse(responseJson, true);
+ } catch (err) {
+ if (err instanceof ResourceNotFoundError) {
+ return responses.notFoundResponse(err.resourceType, err.resourceId);
+ }
+
+ console.error(err);
+ return responses.internalServerErrorResponse(err.message ?? "Unable to fetch person state", true);
+ }
+ } catch (error) {
+ console.error(error);
+ return responses.internalServerErrorResponse(`Unable to complete response: ${error.message}`, true);
+ }
+};
diff --git a/apps/web/modules/ee/contacts/api/client/[environmentId]/contacts/[userId]/attributes/lib/attributes.ts b/apps/web/modules/ee/contacts/lib/attributes.ts
similarity index 75%
rename from apps/web/modules/ee/contacts/api/client/[environmentId]/contacts/[userId]/attributes/lib/attributes.ts
rename to apps/web/modules/ee/contacts/lib/attributes.ts
index 677a126fde..1e110e8056 100644
--- a/apps/web/modules/ee/contacts/api/client/[environmentId]/contacts/[userId]/attributes/lib/attributes.ts
+++ b/apps/web/modules/ee/contacts/lib/attributes.ts
@@ -1,44 +1,18 @@
-import "server-only";
import { contactAttributeCache } from "@/lib/cache/contact-attribute";
import { contactAttributeKeyCache } from "@/lib/cache/contact-attribute-key";
-import { cache as reactCache } from "react";
import { prisma } from "@formbricks/database";
-import { cache } from "@formbricks/lib/cache";
import { MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT } from "@formbricks/lib/constants";
import { validateInputs } from "@formbricks/lib/utils/validate";
import { ZId, ZString } from "@formbricks/types/common";
import { TContactAttributes, ZContactAttributes } from "@formbricks/types/contact-attribute";
-
-export const getContactAttributeKeys = reactCache((environmentId: string) =>
- cache(
- async () => {
- validateInputs([environmentId, ZId]);
-
- const contactAttributes = await prisma.contactAttributeKey.findMany({
- where: {
- environmentId,
- },
- select: {
- id: true,
- key: true,
- },
- });
-
- return contactAttributes;
- },
- [`getContactAttributeKeys-attributes-api-${environmentId}`],
- {
- tags: [contactAttributeKeyCache.tag.byEnvironmentId(environmentId)],
- }
- )()
-);
+import { getContactAttributeKeys } from "./contacts";
export const updateAttributes = async (
contactId: string,
userId: string,
environmentId: string,
contactAttributesParam: TContactAttributes
-): Promise<{ success: boolean; details?: Record }> => {
+): Promise<{ success: boolean; messages?: string[] }> => {
validateInputs(
[contactId, ZId],
[userId, ZString],
@@ -96,9 +70,9 @@ export const updateAttributes = async (
}
);
- let details: Record = emailExists
- ? { email: "The email already exists for this environment and was not updated." }
- : {};
+ let messages: string[] = emailExists
+ ? ["The email already exists for this environment and was not updated."]
+ : [];
// First, update all existing attributes
if (existingAttributes.length > 0) {
@@ -122,9 +96,9 @@ export const updateAttributes = async (
);
// Revalidate cache for existing attributes
- existingAttributes.map(({ key }) =>
- contactAttributeCache.revalidate({ environmentId, contactId, userId, key })
- );
+ for (const attribute of existingAttributes) {
+ contactAttributeCache.revalidate({ environmentId, contactId, userId, key: attribute.key });
+ }
}
// Then, try to create new attributes if any exist
@@ -133,10 +107,9 @@ export const updateAttributes = async (
if (totalAttributeClassesLength > MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT) {
// Add warning to details about skipped attributes
- details = {
- ...details,
- newAttributes: `Could not create ${newAttributes.length} new attribute(s) as it would exceed the maximum limit of ${MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT} attribute classes. Existing attributes were updated successfully.`,
- };
+ messages.push(
+ `Could not create ${newAttributes.length} new attribute(s) as it would exceed the maximum limit of ${MAX_ATTRIBUTE_CLASSES_PER_ENVIRONMENT} attribute classes. Existing attributes were updated successfully.`
+ );
} else {
// Create new attributes since we're under the limit
await prisma.$transaction(
@@ -155,16 +128,17 @@ export const updateAttributes = async (
);
// Batch revalidate caches for new attributes
- newAttributes.forEach(({ key }) => {
- contactAttributeKeyCache.revalidate({ environmentId, key });
- contactAttributeCache.revalidate({ environmentId, contactId, userId, key });
- });
+ for (const attribute of newAttributes) {
+ contactAttributeKeyCache.revalidate({ environmentId, key: attribute.key });
+ contactAttributeCache.revalidate({ environmentId, contactId, userId, key: attribute.key });
+ }
+
contactAttributeKeyCache.revalidate({ environmentId });
}
}
return {
success: true,
- ...(Object.keys(details).length > 0 ? { details } : {}),
+ messages,
};
};
diff --git a/apps/web/modules/ee/contacts/lib/contacts.ts b/apps/web/modules/ee/contacts/lib/contacts.ts
index 6ddfbe8be6..391cad0bcc 100644
--- a/apps/web/modules/ee/contacts/lib/contacts.ts
+++ b/apps/web/modules/ee/contacts/lib/contacts.ts
@@ -148,9 +148,12 @@ export const deleteContact = async (contactId: string): Promise
select: selectContact,
});
+ const contactUserId = contact.attributes.find((attr) => attr.attributeKey.key === "userId")?.value;
+
contactCache.revalidate({
id: contact.id,
environmentId: contact.environmentId,
+ userId: contactUserId,
});
return contact;
diff --git a/apps/web/modules/organization/settings/teams/components/edit-memberships/member-actions.tsx b/apps/web/modules/organization/settings/teams/components/edit-memberships/member-actions.tsx
index ef5e265689..4e5858a4b5 100644
--- a/apps/web/modules/organization/settings/teams/components/edit-memberships/member-actions.tsx
+++ b/apps/web/modules/organization/settings/teams/components/edit-memberships/member-actions.tsx
@@ -56,7 +56,6 @@ export const MemberActions = ({ organization, member, invite, showDeleteButton }
setIsDeleting(false);
router.refresh();
} catch (err) {
- console.log({ err });
setIsDeleting(false);
toast.error(t("common.something_went_wrong_please_try_again"));
}
diff --git a/packages/api/src/api/client/attribute.ts b/packages/api/src/api/client/attribute.ts
index 0aee4cf625..338878108c 100644
--- a/packages/api/src/api/client/attribute.ts
+++ b/packages/api/src/api/client/attribute.ts
@@ -14,9 +14,7 @@ export class AttributeAPI {
async update(
attributeUpdateInput: Omit
- ): Promise<
- Result<{ changed: boolean; message: string; details?: Record }, ApiErrorResponse>
- > {
+ ): Promise> {
// transform all attributes to string if attributes are present into a new attributes copy
const attributes: Record = {};
for (const key in attributeUpdateInput.attributes) {
diff --git a/packages/api/src/api/client/environment.ts b/packages/api/src/api/client/environment.ts
new file mode 100644
index 0000000000..8bc2114f1b
--- /dev/null
+++ b/packages/api/src/api/client/environment.ts
@@ -0,0 +1,18 @@
+import { type Result } from "@formbricks/types/error-handlers";
+import { type ApiErrorResponse } from "@formbricks/types/errors";
+import { type TJsEnvironmentState } from "@formbricks/types/js";
+import { makeRequest } from "../../utils/make-request";
+
+export class EnvironmentAPI {
+ private apiHost: string;
+ private environmentId: string;
+
+ constructor(apiHost: string, environmentId: string) {
+ this.apiHost = apiHost;
+ this.environmentId = environmentId;
+ }
+
+ async getState(): Promise> {
+ return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/environment/`, "GET");
+ }
+}
diff --git a/packages/api/src/api/client/index.ts b/packages/api/src/api/client/index.ts
index e1a5704490..e1932e6b3e 100644
--- a/packages/api/src/api/client/index.ts
+++ b/packages/api/src/api/client/index.ts
@@ -1,14 +1,18 @@
import { type ApiConfig } from "../../types";
import { AttributeAPI } from "./attribute";
import { DisplayAPI } from "./display";
+import { EnvironmentAPI } from "./environment";
import { ResponseAPI } from "./response";
import { StorageAPI } from "./storage";
+import { UserAPI } from "./user";
export class Client {
response: ResponseAPI;
display: DisplayAPI;
storage: StorageAPI;
attribute: AttributeAPI;
+ user: UserAPI;
+ environment: EnvironmentAPI;
constructor(options: ApiConfig) {
const { apiHost, environmentId } = options;
@@ -17,5 +21,7 @@ export class Client {
this.display = new DisplayAPI(apiHost, environmentId);
this.attribute = new AttributeAPI(apiHost, environmentId);
this.storage = new StorageAPI(apiHost, environmentId);
+ this.user = new UserAPI(apiHost, environmentId);
+ this.environment = new EnvironmentAPI(apiHost, environmentId);
}
}
diff --git a/packages/api/src/api/client/user.ts b/packages/api/src/api/client/user.ts
new file mode 100644
index 0000000000..c86378903e
--- /dev/null
+++ b/packages/api/src/api/client/user.ts
@@ -0,0 +1,44 @@
+import { type Result } from "@formbricks/types/error-handlers";
+import { type ApiErrorResponse } from "@formbricks/types/errors";
+import { makeRequest } from "../../utils/make-request";
+
+export class UserAPI {
+ private apiHost: string;
+ private environmentId: string;
+
+ constructor(apiHost: string, environmentId: string) {
+ this.apiHost = apiHost;
+ this.environmentId = environmentId;
+ }
+
+ async createOrUpdate(userUpdateInput: { userId: string; attributes?: Record }): Promise<
+ Result<
+ {
+ state: {
+ expiresAt: Date | null;
+ data: {
+ userId: string | null;
+ segments: string[];
+ displays: { surveyId: string; createdAt: Date }[];
+ responses: string[];
+ lastDisplayAt: Date | null;
+ language?: string;
+ };
+ };
+ messages?: string[];
+ },
+ ApiErrorResponse
+ >
+ > {
+ // transform all attributes to string if attributes are present into a new attributes copy
+ const attributes: Record = {};
+ for (const key in userUpdateInput.attributes) {
+ attributes[key] = String(userUpdateInput.attributes[key]);
+ }
+
+ return makeRequest(this.apiHost, `/api/v1/client/${this.environmentId}/user`, "POST", {
+ userId: userUpdateInput.userId,
+ attributes,
+ });
+ }
+}
diff --git a/packages/config-typescript/react-native-library.json b/packages/config-typescript/react-native-library.json
index 9da691e72a..96a989c4f6 100644
--- a/packages/config-typescript/react-native-library.json
+++ b/packages/config-typescript/react-native-library.json
@@ -6,7 +6,8 @@
"lib": ["dom", "dom.iterable", "ES2022"],
"noEmit": true,
"resolveJsonModule": true,
- "target": "ESNext"
+ "target": "ESNext",
+ "esModuleInterop": true
},
"display": "React Native Library",
"extends": "./base.json"
diff --git a/packages/js-core/src/lib/attributes.ts b/packages/js-core/src/lib/attributes.ts
index fbd2c27924..f62e3489e6 100644
--- a/packages/js-core/src/lib/attributes.ts
+++ b/packages/js-core/src/lib/attributes.ts
@@ -18,7 +18,7 @@ export const updateAttribute = async (
{
changed: boolean;
message: string;
- details?: Record;
+ messages?: string[];
},
ApiErrorResponse
>
@@ -65,10 +65,12 @@ export const updateAttribute = async (
});
}
- if (res.data.details) {
- Object.entries(res.data.details).forEach(([detailsKey, detailsValue]) => {
- logger.error(`${detailsKey}: ${detailsValue}`);
- });
+ const responseMessages = res.data.messages;
+
+ if (responseMessages && responseMessages.length > 0) {
+ for (const message of responseMessages) {
+ logger.debug(message);
+ }
}
if (res.data.changed) {
@@ -79,9 +81,7 @@ export const updateAttribute = async (
value: {
changed: true,
message: "Attribute updated in Formbricks",
- ...(res.data.details && {
- details: res.data.details,
- }),
+ messages: responseMessages,
},
};
}
@@ -91,9 +91,7 @@ export const updateAttribute = async (
value: {
changed: false,
message: "Attribute not updated in Formbricks",
- ...(res.data.details && {
- details: res.data.details,
- }),
+ messages: responseMessages,
},
};
};
@@ -123,10 +121,10 @@ export const updateAttributes = async (
const res = await api.client.attribute.update({ userId, attributes: updatedAttributes });
if (res.ok) {
- if (res.data.details) {
- Object.entries(res.data.details).forEach(([key, value]) => {
- logger.debug(`${key}: ${value}`);
- });
+ if (res.data.messages) {
+ for (const message of res.data.messages) {
+ logger.debug(message);
+ }
}
return ok(updatedAttributes);
diff --git a/packages/js-core/src/lib/constants.ts b/packages/js-core/src/lib/constants.ts
index bf19f961e6..7b57b3e576 100644
--- a/packages/js-core/src/lib/constants.ts
+++ b/packages/js-core/src/lib/constants.ts
@@ -1,4 +1,3 @@
-export const RN_ASYNC_STORAGE_KEY = "formbricks-react-native";
export const JS_LOCAL_STORAGE_KEY = "formbricks-js";
export const LEGACY_JS_WEBSITE_LOCAL_STORAGE_KEY = "formbricks-js-website";
export const LEGACY_JS_APP_LOCAL_STORAGE_KEY = "formbricks-js-app";
diff --git a/packages/js-core/src/lib/environment-state.ts b/packages/js-core/src/lib/environment-state.ts
index bf013b2a4f..c77799b6d9 100644
--- a/packages/js-core/src/lib/environment-state.ts
+++ b/packages/js-core/src/lib/environment-state.ts
@@ -48,13 +48,10 @@ export const fetchEnvironmentState = async (
throw error.error;
}
- const data = (await response.json()) as { data: TJsEnvironmentState["data"] };
+ const data = (await response.json()) as { data: TJsEnvironmentState };
const { data: state } = data;
- return {
- data: { ...state },
- expiresAt: new Date(new Date().getTime() + 1000 * 60 * 30), // 30 minutes
- };
+ return state;
};
/**
diff --git a/packages/react-native/README.md b/packages/react-native/README.md
index 5eae587155..81f446fb41 100644
--- a/packages/react-native/README.md
+++ b/packages/react-native/README.md
@@ -24,20 +24,10 @@ npm install @formbricks/react-native
import Formbricks, { track } from "@formbricks/react-native";
export default function App() {
- const config = {
- environmentId: "your-environment-id",
- apiHost: "https://app.formbricks.com",
- userId: "hello-user", // optional
- attributes: {
- // optional
- plan: "free",
- },
- };
-
return (
{/* Your app code */}
-
+
);
}
diff --git a/packages/react-native/package.json b/packages/react-native/package.json
index b12f772bf2..fbc7639d5c 100644
--- a/packages/react-native/package.json
+++ b/packages/react-native/package.json
@@ -1,6 +1,6 @@
{
"name": "@formbricks/react-native",
- "version": "1.3.1",
+ "version": "2.0.0",
"license": "MIT",
"description": "Formbricks React Native SDK allows you to connect your app to Formbricks, display surveys and trigger events.",
"homepage": "https://formbricks.com",
@@ -36,24 +36,29 @@
"build:dev": "tsc && vite build --mode dev",
"lint": "eslint src --ext .ts,.js,.tsx,.jsx",
"dev": "vite build --watch --mode dev",
- "clean": "rimraf .turbo node_modules dist .turbo"
+ "clean": "rimraf .turbo node_modules dist .turbo",
+ "test": "vitest",
+ "coverage": "vitest run --coverage"
+ },
+ "dependencies": {
+ "zod": "3.24.1"
},
"devDependencies": {
"@formbricks/api": "workspace:*",
"@formbricks/config-typescript": "workspace:*",
- "@formbricks/lib": "workspace:*",
- "@formbricks/types": "workspace:*",
- "@react-native-async-storage/async-storage": "2.1.0",
"@types/react": "18.3.11",
+ "@vitest/coverage-v8": "3.0.4",
"react": "18.3.1",
"react-native": "0.74.5",
"terser": "5.37.0",
"vite": "6.0.9",
- "vite-plugin-dts": "4.3.0"
+ "vite-plugin-dts": "4.3.0",
+ "vitest": "3.0.4"
},
"peerDependencies": {
"react": ">=16.8.0",
"react-native": ">=0.60.0",
- "react-native-webview": ">=13.0.0"
+ "react-native-webview": ">=13.0.0",
+ "@react-native-async-storage/async-storage": ">=2.1.0"
}
}
diff --git a/packages/react-native/src/formbricks.tsx b/packages/react-native/src/components/formbricks.tsx
similarity index 60%
rename from packages/react-native/src/formbricks.tsx
rename to packages/react-native/src/components/formbricks.tsx
index a23b7a18bf..ac0ce6054c 100644
--- a/packages/react-native/src/formbricks.tsx
+++ b/packages/react-native/src/components/formbricks.tsx
@@ -1,26 +1,25 @@
import React, { useCallback, useEffect, useSyncExternalStore } from "react";
-import { type TJsConfigInput } from "@formbricks/types/js";
-import { Logger } from "../../js-core/src/lib/logger";
-import { init } from "./lib";
-import { SurveyStore } from "./lib/survey-store";
-import { SurveyWebView } from "./survey-web-view";
+import { SurveyWebView } from "@/components/survey-web-view";
+import { init } from "@/lib/common/initialize";
+import { Logger } from "@/lib/common/logger";
+import { SurveyStore } from "@/lib/survey/store";
interface FormbricksProps {
- initConfig: TJsConfigInput;
+ appUrl: string;
+ environmentId: string;
}
+
const surveyStore = SurveyStore.getInstance();
const logger = Logger.getInstance();
-export function Formbricks({ initConfig }: FormbricksProps): React.JSX.Element | null {
+export function Formbricks({ appUrl, environmentId }: FormbricksProps): React.JSX.Element | null {
// initializes sdk
useEffect(() => {
const initialize = async (): Promise => {
try {
await init({
- environmentId: initConfig.environmentId,
- apiHost: initConfig.apiHost,
- userId: initConfig.userId,
- attributes: initConfig.attributes,
+ environmentId,
+ appUrl,
});
} catch {
logger.debug("Initialization failed");
@@ -30,7 +29,7 @@ export function Formbricks({ initConfig }: FormbricksProps): React.JSX.Element |
initialize().catch(() => {
logger.debug("Initialization error");
});
- }, [initConfig]);
+ }, [environmentId, appUrl]);
const subscribe = useCallback((callback: () => void) => {
const unsubscribe = surveyStore.subscribe(callback);
diff --git a/packages/react-native/src/survey-web-view.tsx b/packages/react-native/src/components/survey-web-view.tsx
similarity index 77%
rename from packages/react-native/src/survey-web-view.tsx
rename to packages/react-native/src/components/survey-web-view.tsx
index 7031afa565..3aaf684169 100644
--- a/packages/react-native/src/survey-web-view.tsx
+++ b/packages/react-native/src/components/survey-web-view.tsx
@@ -4,19 +4,17 @@ import React, { type JSX, useEffect, useMemo, useRef, useState } from "react";
import { Modal } from "react-native";
import { WebView, type WebViewMessageEvent } from "react-native-webview";
import { FormbricksAPI } from "@formbricks/api";
-import { ResponseQueue } from "@formbricks/lib/responseQueue";
-import { SurveyState } from "@formbricks/lib/surveyState";
-import { getStyling } from "@formbricks/lib/utils/styling";
-import type { SurveyInlineProps } from "@formbricks/types/formbricks-surveys";
-import { ZJsRNWebViewOnMessageData } from "@formbricks/types/js";
-import type { TJsEnvironmentStateSurvey, TJsFileUploadParams, TJsPersonState } from "@formbricks/types/js";
-import type { TResponseUpdate } from "@formbricks/types/responses";
-import type { TUploadFileConfig } from "@formbricks/types/storage";
-import { Logger } from "../../js-core/src/lib/logger";
-import { filterSurveys, getDefaultLanguageCode, getLanguageCode } from "../../js-core/src/lib/utils";
-import { RNConfig } from "./lib/config";
-import { StorageAPI } from "./lib/storage";
-import { SurveyStore } from "./lib/survey-store";
+import { RNConfig } from "@/lib/common/config";
+import { StorageAPI } from "@/lib/common/file-upload";
+import { Logger } from "@/lib/common/logger";
+import { ResponseQueue } from "@/lib/common/response-queue";
+import { filterSurveys, getDefaultLanguageCode, getLanguageCode, getStyling } from "@/lib/common/utils";
+import { SurveyState } from "@/lib/survey/state";
+import { SurveyStore } from "@/lib/survey/store";
+import { type TEnvironmentStateSurvey, type TUserState, ZJsRNWebViewOnMessageData } from "@/types/config";
+import type { TResponseUpdate } from "@/types/response";
+import type { TFileUploadParams, TUploadFileConfig } from "@/types/storage";
+import type { SurveyInlineProps } from "@/types/survey";
const appConfig = RNConfig.getInstance();
const logger = Logger.getInstance();
@@ -25,7 +23,7 @@ logger.configure({ logLevel: "debug" });
const surveyStore = SurveyStore.getInstance();
interface SurveyWebViewProps {
- survey: TJsEnvironmentStateSurvey;
+ survey: TEnvironmentStateSurvey;
}
export function SurveyWebView({ survey }: SurveyWebViewProps): JSX.Element | undefined {
@@ -33,22 +31,23 @@ export function SurveyWebView({ survey }: SurveyWebViewProps): JSX.Element | und
const [isSurveyRunning, setIsSurveyRunning] = useState(false);
const [showSurvey, setShowSurvey] = useState(false);
- const project = appConfig.get().environmentState.data.project;
- const attributes = appConfig.get().attributes;
+ const project = appConfig.get().environment.data.project;
+ const language = appConfig.get().user.data.language;
const styling = getStyling(project, survey);
const isBrandingEnabled = project.inAppSurveyBranding;
const isMultiLanguageSurvey = survey.languages.length > 1;
+ const [languageCode, setLanguageCode] = useState("default");
const [surveyState, setSurveyState] = useState(
- new SurveyState(survey.id, null, null, appConfig.get().personState.data.userId)
+ new SurveyState(survey.id, null, null, appConfig.get().user.data.userId)
);
const responseQueue = useMemo(
() =>
new ResponseQueue(
{
- apiHost: appConfig.get().apiHost,
+ appUrl: appConfig.get().appUrl,
environmentId: appConfig.get().environmentId,
retryAttempts: 2,
setSurveyState,
@@ -59,7 +58,30 @@ export function SurveyWebView({ survey }: SurveyWebViewProps): JSX.Element | und
);
useEffect(() => {
- if (!isSurveyRunning && survey.delay) {
+ if (isMultiLanguageSurvey) {
+ const displayLanguage = getLanguageCode(survey, language);
+ if (!displayLanguage) {
+ logger.debug(`Survey "${survey.name}" is not available in specified language.`);
+ setIsSurveyRunning(false);
+ setShowSurvey(false);
+ surveyStore.resetSurvey();
+ return;
+ }
+ setLanguageCode(displayLanguage);
+ setIsSurveyRunning(true);
+ } else {
+ setIsSurveyRunning(true);
+ }
+ }, [isMultiLanguageSurvey, language, survey]);
+
+ useEffect(() => {
+ if (!isSurveyRunning) {
+ setShowSurvey(false);
+ return;
+ }
+
+ if (survey.delay) {
+ logger.debug(`Delaying survey "${survey.name}" by ${String(survey.delay)} seconds`);
const timerId = setTimeout(() => {
setShowSurvey(true);
}, survey.delay * 1000);
@@ -67,26 +89,13 @@ export function SurveyWebView({ survey }: SurveyWebViewProps): JSX.Element | und
return () => {
clearTimeout(timerId);
};
- } else if (!survey.delay) {
- setShowSurvey(true);
}
- }, [survey.delay, isSurveyRunning]);
- let languageCode = "default";
-
- if (isMultiLanguageSurvey) {
- const displayLanguage = getLanguageCode(survey, attributes);
- //if survey is not available in selected language, survey wont be shown
- if (!displayLanguage) {
- logger.debug(`Survey "${survey.name}" is not available in specified language.`);
- setIsSurveyRunning(true);
- return;
- }
- languageCode = displayLanguage;
- }
+ setShowSurvey(true);
+ }, [survey.delay, isSurveyRunning, survey.name]);
const addResponseToQueue = (responseUpdate: TResponseUpdate): void => {
- const { userId } = appConfig.get().personState.data;
+ const { userId } = appConfig.get().user.data;
if (userId) surveyState.updateUserId(userId);
responseQueue.updateSurveyState(surveyState);
@@ -101,13 +110,13 @@ export function SurveyWebView({ survey }: SurveyWebViewProps): JSX.Element | und
};
const onCloseSurvey = (): void => {
- const { environmentState, personState } = appConfig.get();
+ const { environment: environmentState, user: personState } = appConfig.get();
const filteredSurveys = filterSurveys(environmentState, personState);
appConfig.update({
...appConfig.get(),
- environmentState,
- personState,
+ environment: environmentState,
+ user: personState,
filteredSurveys,
});
@@ -116,10 +125,10 @@ export function SurveyWebView({ survey }: SurveyWebViewProps): JSX.Element | und
};
const createDisplay = async (surveyId: string): Promise<{ id: string }> => {
- const { userId } = appConfig.get().personState.data;
+ const { userId } = appConfig.get().user.data;
const api = new FormbricksAPI({
- apiHost: appConfig.get().apiHost,
+ apiHost: appConfig.get().appUrl,
environmentId: appConfig.get().environmentId,
});
@@ -135,21 +144,19 @@ export function SurveyWebView({ survey }: SurveyWebViewProps): JSX.Element | und
return res.data;
};
- const uploadFile = async (
- file: TJsFileUploadParams["file"],
- params?: TUploadFileConfig
- ): Promise => {
- const storage = new StorageAPI(appConfig.get().apiHost, appConfig.get().environmentId);
+ const uploadFile = async (file: TFileUploadParams["file"], params?: TUploadFileConfig): Promise => {
+ const storage = new StorageAPI(appConfig.get().appUrl, appConfig.get().environmentId);
return await storage.uploadFile(file, params);
};
return (
{
setShowSurvey(false);
+ setIsSurveyRunning(false);
}}>
& { apiHost?: string }): string => {
+const renderHtml = (options: Partial & { appUrl?: string }): string => {
return `
@@ -368,7 +375,6 @@ const renderHtml = (options: Partial & { apiHost?: string }):
window.ReactNativeWebView.postMessage(JSON.stringify({ onRetry: true }));
};
-
window.fileUploadPromiseCallbacks = new Map();
function onFileUpload(file, params) {
@@ -422,7 +428,7 @@ const renderHtml = (options: Partial & { apiHost?: string }):
}
const script = document.createElement("script");
- script.src = "${options.apiHost ?? "http://localhost:3000"}/js/surveys.umd.cjs";
+ script.src = "${options.appUrl ?? "http://localhost:3000"}/js/surveys.umd.cjs";
script.async = true;
script.onload = () => loadSurvey();
script.onerror = (error) => {
diff --git a/packages/react-native/src/index.ts b/packages/react-native/src/index.ts
index 19f187dadf..888a454cb4 100644
--- a/packages/react-native/src/index.ts
+++ b/packages/react-native/src/index.ts
@@ -1,6 +1,42 @@
-import { Formbricks } from "./formbricks";
-import { track } from "./lib";
+import { Formbricks } from "@/components/formbricks";
+import { CommandQueue } from "@/lib/common/command-queue";
+import { Logger } from "@/lib/common/logger";
+import * as Actions from "@/lib/survey/action";
+import * as Attributes from "@/lib/user/attribute";
+import * as User from "@/lib/user/user";
+
+const logger = Logger.getInstance();
+logger.debug("Create command queue");
+const queue = new CommandQueue();
+
+export const track = async (name: string): Promise => {
+ queue.add(Actions.track, true, name);
+ await queue.wait();
+};
+
+export const setUserId = async (userId: string): Promise => {
+ queue.add(User.setUserId, true, userId);
+ await queue.wait();
+};
+
+export const setAttribute = async (key: string, value: string): Promise => {
+ queue.add(Attributes.setAttributes, true, { [key]: value });
+ await queue.wait();
+};
+
+export const setAttributes = async (attributes: Record): Promise => {
+ queue.add(Attributes.setAttributes, true, attributes);
+ await queue.wait();
+};
+
+export const setLanguage = async (language: string): Promise => {
+ queue.add(Attributes.setAttributes, true, { language });
+ await queue.wait();
+};
+
+export const logout = async (): Promise => {
+ queue.add(User.logout, true);
+ await queue.wait();
+};
export default Formbricks;
-
-export { track };
diff --git a/packages/react-native/src/lib/attributes.ts b/packages/react-native/src/lib/attributes.ts
deleted file mode 100644
index 2f861a8bf2..0000000000
--- a/packages/react-native/src/lib/attributes.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { FormbricksAPI } from "@formbricks/api";
-import type { TAttributes } from "@formbricks/types/attributes";
-import type { ApiErrorResponse } from "@formbricks/types/errors";
-import { type Result, err, ok } from "../../../js-core/src/lib/errors";
-import { Logger } from "../../../js-core/src/lib/logger";
-
-const logger = Logger.getInstance();
-
-export const updateAttributes = async (
- apiHost: string,
- environmentId: string,
- userId: string,
- attributes: TAttributes
-): Promise> => {
- // clean attributes and remove existing attributes if config already exists
- const updatedAttributes = { ...attributes };
-
- // send to backend if updatedAttributes is not empty
- if (Object.keys(updatedAttributes).length === 0) {
- logger.debug("No attributes to update. Skipping update.");
- return ok(updatedAttributes);
- }
-
- logger.debug(`Updating attributes: ${JSON.stringify(updatedAttributes)}`);
-
- const api = new FormbricksAPI({
- apiHost,
- environmentId,
- });
-
- const res = await api.client.attribute.update({ userId, attributes: updatedAttributes });
-
- if (res.ok) {
- if (res.data.details) {
- Object.entries(res.data.details).forEach(([key, value]) => {
- logger.debug(`${key}: ${value}`);
- });
- }
-
- return ok(updatedAttributes);
- }
-
- const responseError = res.error;
-
- if (responseError.details?.ignore) {
- logger.error(responseError.message);
- return ok(updatedAttributes);
- }
-
- return err({
- code: responseError.code,
- status: responseError.status,
- message: `Error updating person with userId ${userId}`,
- url: new URL(`${apiHost}/api/v1/client/${environmentId}/people/${userId}/attributes`),
- responseMessage: responseError.responseMessage,
- });
-};
diff --git a/packages/react-native/src/lib/command-queue.ts b/packages/react-native/src/lib/common/command-queue.ts
similarity index 82%
rename from packages/react-native/src/lib/command-queue.ts
rename to packages/react-native/src/lib/common/command-queue.ts
index 717b1dda2e..eb9ed7dea8 100644
--- a/packages/react-native/src/lib/command-queue.ts
+++ b/packages/react-native/src/lib/common/command-queue.ts
@@ -1,6 +1,7 @@
-import { wrapThrowsAsync } from "@formbricks/types/error-handlers";
-import { ErrorHandler, type Result } from "../../../js-core/src/lib/errors";
-import { checkInitialized } from "./initialize";
+/* eslint-disable no-console -- we need to log global errors */
+import { checkInitialized } from "@/lib/common/initialize";
+import { wrapThrowsAsync } from "@/lib/common/utils";
+import type { Result } from "@/types/error";
export class CommandQueue {
private queue: {
@@ -36,7 +37,6 @@ export class CommandQueue {
private async run(): Promise {
this.running = true;
while (this.queue.length > 0) {
- const errorHandler = ErrorHandler.getInstance();
const currentItem = this.queue.shift();
if (!currentItem) continue;
@@ -47,7 +47,6 @@ export class CommandQueue {
const initResult = checkInitialized();
if (!initResult.ok) {
- errorHandler.handle(initResult.error);
continue;
}
}
@@ -59,9 +58,9 @@ export class CommandQueue {
const result = await wrapThrowsAsync(executeCommand)();
if (!result.ok) {
- errorHandler.handle(result.error);
+ console.error("🧱 Formbricks - Global error: ", result.error);
} else if (!result.data.ok) {
- errorHandler.handle(result.data.error);
+ console.error("🧱 Formbricks - Global error: ", result.data.error);
}
}
this.running = false;
diff --git a/packages/react-native/src/lib/config.ts b/packages/react-native/src/lib/common/config.ts
similarity index 73%
rename from packages/react-native/src/lib/config.ts
rename to packages/react-native/src/lib/common/config.ts
index 085dbdb382..6da3f0e827 100644
--- a/packages/react-native/src/lib/config.ts
+++ b/packages/react-native/src/lib/common/config.ts
@@ -1,13 +1,15 @@
/* eslint-disable no-console -- Required for error logging */
-import AsyncStorage from "@react-native-async-storage/async-storage";
-import { type Result, err, ok, wrapThrowsAsync } from "@formbricks/types/error-handlers";
-import type { TJsConfig, TJsConfigUpdateInput } from "@formbricks/types/js";
-import { RN_ASYNC_STORAGE_KEY } from "../../../js-core/src/lib/constants";
+import { AsyncStorage } from "@/lib/common/storage";
+import { wrapThrowsAsync } from "@/lib/common/utils";
+import type { TConfig, TConfigUpdateInput } from "@/types/config";
+import { type Result, err, ok } from "@/types/error";
+
+export const RN_ASYNC_STORAGE_KEY = "formbricks-react-native";
export class RNConfig {
private static instance: RNConfig | null = null;
- private config: TJsConfig | null = null;
+ private config: TConfig | null = null;
private constructor() {
this.loadFromStorage()
@@ -29,7 +31,7 @@ export class RNConfig {
return RNConfig.instance;
}
- public update(newConfig: TJsConfigUpdateInput): void {
+ public update(newConfig: TConfigUpdateInput): void {
this.config = {
...this.config,
...newConfig,
@@ -42,24 +44,24 @@ export class RNConfig {
void this.saveToStorage();
}
- public get(): TJsConfig {
+ public get(): TConfig {
if (!this.config) {
throw new Error("config is null, maybe the init function was not called?");
}
return this.config;
}
- public async loadFromStorage(): Promise> {
+ public async loadFromStorage(): Promise> {
try {
const savedConfig = await AsyncStorage.getItem(RN_ASYNC_STORAGE_KEY);
if (savedConfig) {
- const parsedConfig = JSON.parse(savedConfig) as TJsConfig;
+ const parsedConfig = JSON.parse(savedConfig) as TConfig;
// check if the config has expired
if (
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- need to check if expiresAt is set
- parsedConfig.environmentState.expiresAt &&
- new Date(parsedConfig.environmentState.expiresAt) <= new Date()
+ parsedConfig.environment.expiresAt &&
+ new Date(parsedConfig.environment.expiresAt) <= new Date()
) {
return err(new Error("Config in local storage has expired"));
}
diff --git a/packages/react-native/src/lib/event-listeners.ts b/packages/react-native/src/lib/common/event-listeners.ts
similarity index 71%
rename from packages/react-native/src/lib/event-listeners.ts
rename to packages/react-native/src/lib/common/event-listeners.ts
index ecd46179cc..f2fea20f12 100644
--- a/packages/react-native/src/lib/event-listeners.ts
+++ b/packages/react-native/src/lib/common/event-listeners.ts
@@ -1,32 +1,32 @@
import {
addEnvironmentStateExpiryCheckListener,
clearEnvironmentStateExpiryCheckListener,
-} from "./environment-state";
-import { addPersonStateExpiryCheckListener, clearPersonStateExpiryCheckListener } from "./person-state";
+} from "@/lib/environment/state";
+import { addUserStateExpiryCheckListener, clearUserStateExpiryCheckListener } from "@/lib/user/state";
let areRemoveEventListenersAdded = false;
export const addEventListeners = (): void => {
addEnvironmentStateExpiryCheckListener();
- addPersonStateExpiryCheckListener();
+ addUserStateExpiryCheckListener();
};
export const addCleanupEventListeners = (): void => {
if (areRemoveEventListenersAdded) return;
clearEnvironmentStateExpiryCheckListener();
- clearPersonStateExpiryCheckListener();
+ clearUserStateExpiryCheckListener();
areRemoveEventListenersAdded = true;
};
export const removeCleanupEventListeners = (): void => {
if (!areRemoveEventListenersAdded) return;
clearEnvironmentStateExpiryCheckListener();
- clearPersonStateExpiryCheckListener();
+ clearUserStateExpiryCheckListener();
areRemoveEventListenersAdded = false;
};
export const removeAllEventListeners = (): void => {
clearEnvironmentStateExpiryCheckListener();
- clearPersonStateExpiryCheckListener();
+ clearUserStateExpiryCheckListener();
removeCleanupEventListeners();
};
diff --git a/packages/react-native/src/lib/storage.ts b/packages/react-native/src/lib/common/file-upload.ts
similarity index 91%
rename from packages/react-native/src/lib/storage.ts
rename to packages/react-native/src/lib/common/file-upload.ts
index ff956c72b7..a4fb21d703 100644
--- a/packages/react-native/src/lib/storage.ts
+++ b/packages/react-native/src/lib/common/file-upload.ts
@@ -1,12 +1,12 @@
/* eslint-disable no-console -- used for error logging */
-import type { TUploadFileConfig, TUploadFileResponse } from "@formbricks/types/storage";
+import { type TUploadFileConfig, type TUploadFileResponse } from "@/types/storage";
export class StorageAPI {
- private apiHost: string;
+ private appUrl: string;
private environmentId: string;
- constructor(apiHost: string, environmentId: string) {
- this.apiHost = apiHost;
+ constructor(appUrl: string, environmentId: string) {
+ this.appUrl = appUrl;
this.environmentId = environmentId;
}
@@ -29,7 +29,7 @@ export class StorageAPI {
surveyId,
};
- const response = await fetch(`${this.apiHost}/api/v1/client/${this.environmentId}/storage`, {
+ const response = await fetch(`${this.appUrl}/api/v1/client/${this.environmentId}/storage`, {
method: "POST",
headers: {
"Content-Type": "application/json",
@@ -86,7 +86,7 @@ export class StorageAPI {
let uploadResponse: Response = {} as Response;
- const signedUrlCopy = signedUrl.replace("http://localhost:3000", this.apiHost);
+ const signedUrlCopy = signedUrl.replace("http://localhost:3000", this.appUrl);
try {
uploadResponse = await fetch(signedUrlCopy, {
diff --git a/packages/react-native/src/lib/common/initialize.ts b/packages/react-native/src/lib/common/initialize.ts
new file mode 100644
index 0000000000..f5bd007297
--- /dev/null
+++ b/packages/react-native/src/lib/common/initialize.ts
@@ -0,0 +1,281 @@
+import { RNConfig, RN_ASYNC_STORAGE_KEY } from "@/lib/common/config";
+import {
+ addCleanupEventListeners,
+ addEventListeners,
+ removeAllEventListeners,
+} from "@/lib/common/event-listeners";
+import { Logger } from "@/lib/common/logger";
+import { AsyncStorage } from "@/lib/common/storage";
+import { filterSurveys, isNowExpired, wrapThrowsAsync } from "@/lib/common/utils";
+import { fetchEnvironmentState } from "@/lib/environment/state";
+import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state";
+import { sendUpdatesToBackend } from "@/lib/user/update";
+import { type TConfig, type TConfigInput, type TEnvironmentState, type TUserState } from "@/types/config";
+import {
+ type MissingFieldError,
+ type MissingPersonError,
+ type NetworkError,
+ type NotInitializedError,
+ type Result,
+ err,
+ okVoid,
+} from "@/types/error";
+
+let isInitialized = false;
+
+export const setIsInitialize = (state: boolean): void => {
+ isInitialized = state;
+};
+
+export const init = async (
+ configInput: TConfigInput
+): Promise> => {
+ const appConfig = RNConfig.getInstance();
+ const logger = Logger.getInstance();
+
+ if (isInitialized) {
+ logger.debug("Already initialized, skipping initialization.");
+ return okVoid();
+ }
+
+ let existingConfig: TConfig | undefined;
+ try {
+ existingConfig = appConfig.get();
+ logger.debug("Found existing configuration.");
+ } catch {
+ logger.debug("No existing configuration found.");
+ }
+
+ // formbricks is in error state, skip initialization
+ if (existingConfig?.status.value === "error") {
+ logger.debug("Formbricks was set to an error state.");
+
+ const expiresAt = existingConfig.status.expiresAt;
+
+ if (expiresAt && isNowExpired(expiresAt)) {
+ logger.debug("Error state is not expired, skipping initialization");
+ return okVoid();
+ }
+ logger.debug("Error state is expired. Continue with initialization.");
+ }
+
+ logger.debug("Start initialize");
+
+ if (!configInput.environmentId) {
+ logger.debug("No environmentId provided");
+ return err({
+ code: "missing_field",
+ field: "environmentId",
+ });
+ }
+
+ if (!configInput.appUrl) {
+ logger.debug("No appUrl provided");
+
+ return err({
+ code: "missing_field",
+ field: "appUrl",
+ });
+ }
+
+ if (
+ existingConfig?.environment &&
+ existingConfig.environmentId === configInput.environmentId &&
+ existingConfig.appUrl === configInput.appUrl
+ ) {
+ logger.debug("Configuration fits init parameters.");
+ let isEnvironmentStateExpired = false;
+ let isUserStateExpired = false;
+
+ if (isNowExpired(existingConfig.environment.expiresAt)) {
+ logger.debug("Environment state expired. Syncing.");
+ isEnvironmentStateExpired = true;
+ }
+
+ if (existingConfig.user.expiresAt && isNowExpired(existingConfig.user.expiresAt)) {
+ logger.debug("Person state expired. Syncing.");
+ isUserStateExpired = true;
+ }
+
+ try {
+ // fetch the environment state (if expired)
+ let environmentState: TEnvironmentState = existingConfig.environment;
+ let userState: TUserState = existingConfig.user;
+
+ if (isEnvironmentStateExpired) {
+ const environmentStateResponse = await fetchEnvironmentState({
+ appUrl: configInput.appUrl,
+ environmentId: configInput.environmentId,
+ });
+
+ if (environmentStateResponse.ok) {
+ environmentState = environmentStateResponse.data;
+ } else {
+ logger.error(
+ `Error fetching environment state: ${environmentStateResponse.error.code} - ${environmentStateResponse.error.responseMessage ?? ""}`
+ );
+ return err({
+ code: "network_error",
+ message: "Error fetching environment state",
+ status: 500,
+ url: new URL(`${configInput.appUrl}/api/v1/client/${configInput.environmentId}/environment`),
+ responseMessage: environmentStateResponse.error.message,
+ });
+ }
+ }
+
+ if (isUserStateExpired) {
+ // If the existing person state (expired) has a userId, we need to fetch the person state
+ // If the existing person state (expired) has no userId, we need to set the person state to the default
+
+ if (userState.data.userId) {
+ const updatesResponse = await sendUpdatesToBackend({
+ appUrl: configInput.appUrl,
+ environmentId: configInput.environmentId,
+ updates: {
+ userId: userState.data.userId,
+ },
+ });
+
+ if (updatesResponse.ok) {
+ userState = updatesResponse.data.state;
+ } else {
+ logger.error(
+ `Error updating user state: ${updatesResponse.error.code} - ${updatesResponse.error.responseMessage ?? ""}`
+ );
+ return err({
+ code: "network_error",
+ message: "Error updating user state",
+ status: 500,
+ url: new URL(
+ `${configInput.appUrl}/api/v1/client/${configInput.environmentId}/update/contacts/${userState.data.userId}`
+ ),
+ responseMessage: "Unknown error",
+ });
+ }
+ } else {
+ userState = DEFAULT_USER_STATE_NO_USER_ID;
+ }
+ }
+
+ // filter the environment state wrt the person state
+ const filteredSurveys = filterSurveys(environmentState, userState);
+
+ // update the appConfig with the new filtered surveys and person state
+ appConfig.update({
+ ...existingConfig,
+ environment: environmentState,
+ user: userState,
+ filteredSurveys,
+ });
+
+ const surveyNames = filteredSurveys.map((s) => s.name);
+ logger.debug(`Fetched ${surveyNames.length.toString()} surveys during sync: ${surveyNames.join(", ")}`);
+ } catch {
+ logger.debug("Error during sync. Please try again.");
+ }
+ } else {
+ logger.debug("No valid configuration found. Resetting config and creating new one.");
+ void appConfig.resetConfig();
+ logger.debug("Syncing.");
+
+ // During init, if we don't have a valid config, we need to fetch the environment state
+ // but not the person state, we can set it to the default value.
+ // The person state will be fetched when the `setUserId` method is called.
+
+ try {
+ const environmentStateResponse = await fetchEnvironmentState({
+ appUrl: configInput.appUrl,
+ environmentId: configInput.environmentId,
+ });
+
+ if (!environmentStateResponse.ok) {
+ // eslint-disable-next-line @typescript-eslint/only-throw-error -- error is ApiErrorResponse
+ throw environmentStateResponse.error;
+ }
+
+ const personState = DEFAULT_USER_STATE_NO_USER_ID;
+ const environmentState = environmentStateResponse.data;
+
+ const filteredSurveys = filterSurveys(environmentState, personState);
+
+ appConfig.update({
+ appUrl: configInput.appUrl,
+ environmentId: configInput.environmentId,
+ user: personState,
+ environment: environmentState,
+ filteredSurveys,
+ });
+ } catch (e) {
+ await handleErrorOnFirstInit(e as { code: string; responseMessage: string });
+ }
+ }
+
+ logger.debug("Adding event listeners");
+ addEventListeners();
+ addCleanupEventListeners();
+
+ setIsInitialize(true);
+ logger.debug("Initialized");
+
+ // check page url if initialized after page load
+ return okVoid();
+};
+
+export const checkInitialized = (): Result => {
+ const logger = Logger.getInstance();
+ logger.debug("Check if initialized");
+
+ if (!isInitialized) {
+ return err({
+ code: "not_initialized",
+ message: "Formbricks not initialized. Call initialize() first.",
+ });
+ }
+
+ return okVoid();
+};
+
+// eslint-disable-next-line @typescript-eslint/require-await -- disabled for now
+export const deinitalize = async (): Promise => {
+ const logger = Logger.getInstance();
+ const appConfig = RNConfig.getInstance();
+
+ logger.debug("Setting person state to default");
+ // clear the user state and set it to the default value
+ appConfig.update({
+ ...appConfig.get(),
+ user: DEFAULT_USER_STATE_NO_USER_ID,
+ });
+ setIsInitialize(false);
+ removeAllEventListeners();
+};
+
+export const handleErrorOnFirstInit = async (e: {
+ code: string;
+ responseMessage: string;
+}): Promise => {
+ const logger = Logger.getInstance();
+
+ if (e.code === "forbidden") {
+ logger.error(`Authorization error: ${e.responseMessage}`);
+ } else {
+ logger.error(
+ `Error during first initialization: ${e.code} - ${e.responseMessage}. Please try again later.`
+ );
+ }
+
+ // put formbricks in error state (by creating a new config) and throw error
+ const initialErrorConfig: Partial = {
+ status: {
+ value: "error",
+ expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future
+ },
+ };
+
+ await wrapThrowsAsync(async () => {
+ await AsyncStorage.setItem(RN_ASYNC_STORAGE_KEY, JSON.stringify(initialErrorConfig));
+ })();
+
+ throw new Error("Could not initialize formbricks");
+};
diff --git a/packages/react-native/src/lib/common/logger.ts b/packages/react-native/src/lib/common/logger.ts
new file mode 100644
index 0000000000..5d45ffe90e
--- /dev/null
+++ b/packages/react-native/src/lib/common/logger.ts
@@ -0,0 +1,50 @@
+/* eslint-disable no-console -- Required for logging */
+type LogLevel = "debug" | "error";
+
+interface LoggerConfig {
+ logLevel?: LogLevel;
+}
+
+export class Logger {
+ private static instance: Logger | undefined;
+ private logLevel: LogLevel = "error";
+
+ static getInstance(): Logger {
+ if (!Logger.instance) {
+ Logger.instance = new Logger();
+ }
+ return Logger.instance;
+ }
+
+ configure(config: LoggerConfig): void {
+ if (config.logLevel !== undefined) {
+ this.logLevel = config.logLevel;
+ }
+ }
+
+ private logger(message: string, level: LogLevel): void {
+ if (level === "debug" && this.logLevel !== "debug") {
+ return;
+ }
+
+ const timestamp = new Date().toISOString();
+ const logMessage = `🧱 Formbricks - ${timestamp} [${level.toUpperCase()}] - ${message}`;
+ if (level === "error") {
+ console.error(logMessage);
+ } else {
+ console.log(logMessage);
+ }
+ }
+
+ debug(message: string): void {
+ this.logger(message, "debug");
+ }
+
+ error(message: string): void {
+ this.logger(message, "error");
+ }
+
+ public resetInstance(): void {
+ Logger.instance = undefined;
+ }
+}
diff --git a/packages/react-native/src/lib/common/response-queue.ts b/packages/react-native/src/lib/common/response-queue.ts
new file mode 100644
index 0000000000..35c0d8524c
--- /dev/null
+++ b/packages/react-native/src/lib/common/response-queue.ts
@@ -0,0 +1,119 @@
+/* eslint-disable no-console -- required for logging errors */
+import { FormbricksAPI } from "@formbricks/api";
+import { type SurveyState } from "@/lib/survey/state";
+import { type TResponseUpdate } from "@/types/response";
+
+interface QueueConfig {
+ appUrl: string;
+ environmentId: string;
+ retryAttempts: number;
+ onResponseSendingFailed?: (responseUpdate: TResponseUpdate) => void;
+ onResponseSendingFinished?: () => void;
+ setSurveyState?: (state: SurveyState) => void;
+}
+
+const delay = (ms: number): Promise => {
+ return new Promise((resolve) => {
+ setTimeout(resolve, ms);
+ });
+};
+
+export class ResponseQueue {
+ private queue: TResponseUpdate[] = [];
+ private config: QueueConfig;
+ private surveyState: SurveyState;
+ private isRequestInProgress = false;
+ private api: FormbricksAPI;
+
+ constructor(config: QueueConfig, surveyState: SurveyState, apiInstance?: FormbricksAPI) {
+ this.config = config;
+ this.surveyState = surveyState;
+ this.api =
+ apiInstance ??
+ new FormbricksAPI({
+ apiHost: config.appUrl,
+ environmentId: config.environmentId,
+ });
+ }
+
+ add(responseUpdate: TResponseUpdate): void {
+ // update survey state
+ this.surveyState.accumulateResponse(responseUpdate);
+ if (this.config.setSurveyState) {
+ this.config.setSurveyState(this.surveyState);
+ }
+ // add response to queue
+ this.queue.push(responseUpdate);
+ void this.processQueue();
+ }
+
+ async processQueue(): Promise {
+ if (this.isRequestInProgress) return;
+ if (this.queue.length === 0) return;
+
+ this.isRequestInProgress = true;
+
+ const responseUpdate = this.queue[0];
+ let attempts = 0;
+
+ while (attempts < this.config.retryAttempts) {
+ const success = await this.sendResponse(responseUpdate);
+ if (success) {
+ this.queue.shift(); // remove the successfully sent response from the queue
+ break; // exit the retry loop
+ }
+ console.error(`Formbricks: Failed to send response. Retrying... ${attempts.toString()}`);
+ await delay(1000); // wait for 1 second before retrying
+ attempts++;
+ }
+
+ if (attempts >= this.config.retryAttempts) {
+ // Inform the user after 2 failed attempts
+ console.error("Failed to send response after 2 attempts.");
+ // If the response fails finally, inform the user
+ if (this.config.onResponseSendingFailed) {
+ this.config.onResponseSendingFailed(responseUpdate);
+ }
+ this.isRequestInProgress = false;
+ } else {
+ if (responseUpdate.finished && this.config.onResponseSendingFinished) {
+ this.config.onResponseSendingFinished();
+ }
+ this.isRequestInProgress = false;
+ void this.processQueue(); // process the next item in the queue if any
+ }
+ }
+
+ async sendResponse(responseUpdate: TResponseUpdate): Promise {
+ try {
+ if (this.surveyState.responseId !== null) {
+ await this.api.client.response.update({ ...responseUpdate, responseId: this.surveyState.responseId });
+ } else {
+ const response = await this.api.client.response.create({
+ ...responseUpdate,
+ surveyId: this.surveyState.surveyId,
+ userId: this.surveyState.userId ?? null,
+ singleUseId: this.surveyState.singleUseId ?? null,
+ data: { ...responseUpdate.data, ...responseUpdate.hiddenFields },
+ displayId: this.surveyState.displayId,
+ });
+ if (!response.ok) {
+ throw new Error("Could not create response");
+ }
+ this.surveyState.updateResponseId(response.data.id);
+ if (this.config.setSurveyState) {
+ this.config.setSurveyState(this.surveyState);
+ }
+ }
+ return true;
+ } catch (error) {
+ console.error(error);
+ return false;
+ }
+ }
+
+ // update surveyState
+ updateSurveyState(surveyState: SurveyState): void {
+ this.surveyState = surveyState;
+ }
+}
diff --git a/packages/react-native/src/lib/common/storage.ts b/packages/react-native/src/lib/common/storage.ts
new file mode 100644
index 0000000000..a3a4ecacff
--- /dev/null
+++ b/packages/react-native/src/lib/common/storage.ts
@@ -0,0 +1,9 @@
+import AsyncStorageModule from "@react-native-async-storage/async-storage";
+
+const AsyncStorageWithDefault = AsyncStorageModule as typeof AsyncStorageModule & {
+ default?: typeof AsyncStorageModule;
+};
+
+const AsyncStorage = AsyncStorageWithDefault.default ?? AsyncStorageModule;
+
+export { AsyncStorage };
diff --git a/packages/react-native/src/lib/common/tests/__mocks__/config.mock.ts b/packages/react-native/src/lib/common/tests/__mocks__/config.mock.ts
new file mode 100644
index 0000000000..1eb35379ca
--- /dev/null
+++ b/packages/react-native/src/lib/common/tests/__mocks__/config.mock.ts
@@ -0,0 +1,126 @@
+import type { TConfig } from "@/types/config";
+
+// ids
+export const mockEnvironmentId = "ggskhsue85p2xrxrc7x3qagg";
+export const mockProjectId = "f5kptre0saxmltl7ram364qt";
+export const mockLanguageId = "n4ts6u7wy5lbn4q3jovikqot";
+export const mockSurveyId = "lz5m554yqh1i3moa3y230wei";
+export const mockActionClassId = "wypzu5qw7adgy66vq8s77tso";
+
+export const mockConfig: TConfig = {
+ environmentId: mockEnvironmentId,
+ appUrl: "https://myapp.example",
+ environment: {
+ expiresAt: "2999-12-31T23:59:59Z",
+ data: {
+ surveys: [
+ {
+ id: mockSurveyId,
+ name: "Onboarding Survey",
+ welcomeCard: null,
+ questions: [],
+ variables: [],
+ type: "app", // "link" or "app"
+ showLanguageSwitch: true,
+ endings: [],
+ autoClose: 5,
+ status: "inProgress", // whatever statuses you use
+ recontactDays: 7,
+ displayLimit: 1,
+ displayOption: "displayMultiple",
+ hiddenFields: [],
+ delay: 5, // e.g. 5s
+ projectOverwrites: {},
+ languages: [
+ {
+ // SurveyLanguage fields
+ surveyId: mockSurveyId,
+ default: true,
+ enabled: true,
+ languageId: mockLanguageId,
+ language: {
+ // Language fields
+ id: mockLanguageId,
+ code: "en",
+ alias: "en",
+ createdAt: "2025-01-01T10:00:00Z",
+ updatedAt: "2025-01-01T10:00:00Z",
+ projectId: mockProjectId,
+ },
+ },
+ ],
+ triggers: [
+ {
+ // { actionClass: ActionClass }
+ actionClass: {
+ id: mockActionClassId,
+ key: "onboardingTrigger",
+ type: "code", // or "automatic"
+ name: "Manual Trigger",
+ createdAt: "2025-01-01T10:00:00Z",
+ updatedAt: "2025-01-01T10:00:00Z",
+ environmentId: mockEnvironmentId,
+ description: "Manual Trigger",
+ noCodeConfig: {},
+ },
+ },
+ ],
+ segment: undefined, // or mock your Segment if needed
+ displayPercentage: 100,
+ styling: {
+ // TSurveyStyling
+ overwriteThemeStyling: false,
+ brandColor: { light: "#2B6CB0" },
+ },
+ },
+ ],
+ actionClasses: [
+ {
+ id: mockActionClassId,
+ key: "onboardingTrigger",
+ type: "code",
+ name: "Manual Trigger",
+ noCodeConfig: {},
+ },
+ ],
+ project: {
+ id: mockProjectId,
+ recontactDays: 14,
+ clickOutsideClose: true,
+ darkOverlay: false,
+ placement: "bottomRight",
+ inAppSurveyBranding: true,
+ styling: {
+ // TProjectStyling
+ allowStyleOverwrite: true,
+ brandColor: { light: "#319795" },
+ },
+ },
+ },
+ },
+ user: {
+ expiresAt: null,
+ data: {
+ userId: "user_abc",
+ segments: ["beta-testers"],
+ displays: [
+ {
+ surveyId: mockSurveyId,
+ createdAt: "2025-01-01T10:00:00Z",
+ },
+ ],
+ responses: [mockSurveyId],
+ lastDisplayAt: "2025-01-02T15:00:00Z",
+ language: "en",
+ },
+ },
+ filteredSurveys: [], // fill if you'd like to pre-filter any surveys
+ attributes: {
+ plan: "premium",
+ region: "US",
+ },
+ status: {
+ value: "success",
+ expiresAt: null,
+ },
+} as unknown as TConfig;
diff --git a/packages/react-native/src/lib/common/tests/__mocks__/response-queue.mock.ts b/packages/react-native/src/lib/common/tests/__mocks__/response-queue.mock.ts
new file mode 100644
index 0000000000..0238837726
--- /dev/null
+++ b/packages/react-native/src/lib/common/tests/__mocks__/response-queue.mock.ts
@@ -0,0 +1,10 @@
+export const mockSurveyId = "v6ym64bkch8o2spfajqt95u4";
+export const mockDisplayId = "athxslnasdkkoo18dyjs8y7e";
+export const mockEnvironmentId = "t49wnfhgq9cvarkvdq4316fd";
+export const mockUserId = "user_abc";
+export const mockAppUrl = "https://app.formbricks.com";
+export const mockResponseId = "d5596l4pynnqv03dokbnril4";
+export const mockQuestionId = "u9cnqpimopizdukyry8ye5us";
+export const mockResponseData = {
+ [mockQuestionId]: "test",
+};
diff --git a/packages/react-native/src/lib/common/tests/command-queue.test.ts b/packages/react-native/src/lib/common/tests/command-queue.test.ts
new file mode 100644
index 0000000000..8d06ea2182
--- /dev/null
+++ b/packages/react-native/src/lib/common/tests/command-queue.test.ts
@@ -0,0 +1,165 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { CommandQueue } from "@/lib/common/command-queue";
+import { checkInitialized } from "@/lib/common/initialize";
+import { type Result } from "@/types/error";
+
+// Mock the initialize module so we can control checkInitialized()
+vi.mock("@/lib/common/initialize", () => ({
+ checkInitialized: vi.fn(),
+}));
+
+describe("CommandQueue", () => {
+ let queue: CommandQueue;
+
+ beforeEach(() => {
+ // Clear all mocks before each test
+ vi.clearAllMocks();
+ // Create a fresh CommandQueue instance
+ queue = new CommandQueue();
+ });
+
+ test("executes commands in FIFO order", async () => {
+ const executionOrder: string[] = [];
+
+ // Mock commands with proper Result returns
+ const cmdA = vi.fn(async (): Promise> => {
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ executionOrder.push("A");
+ resolve({ ok: true, data: undefined });
+ }, 10);
+ });
+ });
+ const cmdB = vi.fn(async (): Promise> => {
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ executionOrder.push("B");
+ resolve({ ok: true, data: undefined });
+ }, 10);
+ });
+ });
+ const cmdC = vi.fn(async (): Promise> => {
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ executionOrder.push("C");
+ resolve({ ok: true, data: undefined });
+ }, 10);
+ });
+ });
+
+ // We'll assume checkInitialized always ok for this test
+ vi.mocked(checkInitialized).mockReturnValue({ ok: true, data: undefined });
+
+ // Enqueue commands
+ queue.add(cmdA, true);
+ queue.add(cmdB, true);
+ queue.add(cmdC, true);
+
+ // Wait for them to finish
+ await queue.wait();
+
+ expect(executionOrder).toEqual(["A", "B", "C"]);
+ });
+
+ test("skips execution if checkInitialized() fails", async () => {
+ const cmd = vi.fn(async (): Promise => {
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve();
+ }, 10);
+ });
+ });
+
+ // Force checkInitialized to fail
+ vi.mocked(checkInitialized).mockReturnValue({
+ ok: false,
+ error: {
+ code: "not_initialized",
+ message: "Not initialized",
+ },
+ });
+
+ queue.add(cmd, true);
+ await queue.wait();
+
+ // Command should never have been called
+ expect(cmd).not.toHaveBeenCalled();
+ });
+
+ test("executes command if checkInitialized is false (no check)", async () => {
+ const cmd = vi.fn(async (): Promise> => {
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve({ ok: true, data: undefined });
+ }, 10);
+ });
+ });
+
+ // checkInitialized is irrelevant in this scenario, but let's mock it anyway
+ vi.mocked(checkInitialized).mockReturnValue({ ok: true, data: undefined });
+
+ // Here we pass 'false' for the second argument, so no check is performed
+ queue.add(cmd, false);
+ await queue.wait();
+
+ expect(cmd).toHaveBeenCalledTimes(1);
+ });
+
+ test("logs errors if a command throws or returns error", async () => {
+ // Spy on console.error to see if it's called
+ const consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {
+ return {
+ ok: true,
+ data: undefined,
+ };
+ });
+
+ // Force checkInitialized to succeed
+ vi.mocked(checkInitialized).mockReturnValue({ ok: true, data: undefined });
+
+ // Mock command that fails
+ const failingCmd = vi.fn(async () => {
+ await new Promise((resolve) => {
+ setTimeout(() => {
+ resolve("some error");
+ }, 10);
+ });
+
+ throw new Error("some error");
+ });
+
+ queue.add(failingCmd, true);
+ await queue.wait();
+
+ expect(consoleErrorSpy).toHaveBeenCalledWith("🧱 Formbricks - Global error: ", expect.any(Error));
+ consoleErrorSpy.mockRestore();
+ });
+
+ test("resolves wait() after all commands complete", async () => {
+ const cmd1 = vi.fn(async (): Promise> => {
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve({ ok: true, data: undefined });
+ }, 10);
+ });
+ });
+ const cmd2 = vi.fn(async (): Promise> => {
+ return new Promise((resolve) => {
+ setTimeout(() => {
+ resolve({ ok: true, data: undefined });
+ }, 10);
+ });
+ });
+
+ vi.mocked(checkInitialized).mockReturnValue({ ok: true, data: undefined });
+
+ queue.add(cmd1, true);
+ queue.add(cmd2, true);
+
+ await queue.wait();
+
+ // By the time `await queue.wait()` resolves, both commands should be done
+ expect(cmd1).toHaveBeenCalled();
+ expect(cmd2).toHaveBeenCalled();
+ });
+});
diff --git a/packages/react-native/src/lib/common/tests/config.test.ts b/packages/react-native/src/lib/common/tests/config.test.ts
new file mode 100644
index 0000000000..4d42f84102
--- /dev/null
+++ b/packages/react-native/src/lib/common/tests/config.test.ts
@@ -0,0 +1,127 @@
+// config.test.ts
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { RNConfig, RN_ASYNC_STORAGE_KEY } from "@/lib/common/config";
+import type { TConfig, TConfigUpdateInput } from "@/types/config";
+import { mockConfig } from "./__mocks__/config.mock";
+
+// Define mocks outside of any describe block
+
+describe("RNConfig", () => {
+ let configInstance: RNConfig;
+
+ beforeEach(async () => {
+ // Clear mocks between tests
+ vi.clearAllMocks();
+
+ // get the config instance
+ configInstance = RNConfig.getInstance();
+
+ // reset the config
+ await configInstance.resetConfig();
+
+ // get the config instance again
+ configInstance = RNConfig.getInstance();
+ });
+
+ afterEach(() => {
+ // In case we want to restore them after all tests
+ vi.restoreAllMocks();
+ });
+
+ test("getInstance() returns a singleton", () => {
+ const secondInstance = RNConfig.getInstance();
+ expect(configInstance).toBe(secondInstance);
+ });
+
+ test("get() throws if config is null", () => {
+ // constructor didn't load anything successfully
+ // so config is still null
+ expect(() => configInstance.get()).toThrow("config is null, maybe the init function was not called?");
+ });
+
+ test("loadFromStorage() returns ok if valid config is found", async () => {
+ vi.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce(JSON.stringify(mockConfig));
+
+ const result = await configInstance.loadFromStorage();
+ expect(result.ok).toBe(true);
+
+ if (result.ok) {
+ expect(result.data).toEqual(mockConfig);
+ }
+ });
+
+ test("loadFromStorage() returns err if config is expired", async () => {
+ const expiredConfig = {
+ ...mockConfig,
+ environment: {
+ ...mockConfig.environment,
+ expiresAt: new Date("2000-01-01T00:00:00Z"),
+ },
+ };
+
+ vi.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce(JSON.stringify(expiredConfig));
+
+ const result = await configInstance.loadFromStorage();
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.error.message).toBe("Config in local storage has expired");
+ }
+ });
+
+ test("loadFromStorage() returns err if no or invalid config in storage", async () => {
+ // Simulate no data
+ vi.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce(null);
+
+ const result = await configInstance.loadFromStorage();
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.error.message).toBe("No or invalid config in local storage");
+ }
+ });
+
+ test("update() merges new config, calls saveToStorage()", async () => {
+ vi.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce(JSON.stringify(mockConfig));
+
+ // Wait for the constructor's async load
+ await new Promise(setImmediate);
+
+ // Now we call update()
+ const newStatus = { value: "error", expiresAt: "2100-01-01T00:00:00Z" } as unknown as TConfig["status"];
+
+ configInstance.update({ ...mockConfig, status: newStatus } as unknown as TConfigUpdateInput);
+
+ // The update call should eventually call setItem on AsyncStorage
+ expect(AsyncStorage.setItem).toHaveBeenCalled();
+ // Let’s check if we can read the updated config:
+ const updatedConfig = configInstance.get();
+ expect(updatedConfig.status.value).toBe("error");
+ expect(updatedConfig.status.expiresAt).toBe("2100-01-01T00:00:00Z");
+ });
+
+ test("saveToStorage() is invoked internally on update()", async () => {
+ vi.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce(JSON.stringify(mockConfig));
+
+ await new Promise(setImmediate);
+
+ configInstance.update({ status: { value: "success", expiresAt: null } } as unknown as TConfigUpdateInput);
+ expect(AsyncStorage.setItem).toHaveBeenCalledWith(
+ RN_ASYNC_STORAGE_KEY,
+ expect.any(String) // the JSON string
+ );
+ });
+
+ test("resetConfig() clears config and AsyncStorage", async () => {
+ vi.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce(JSON.stringify(mockConfig));
+ await new Promise(setImmediate);
+
+ // Now reset
+ const result = await configInstance.resetConfig();
+
+ expect(result.ok).toBe(true);
+ // config is now null
+ expect(() => configInstance.get()).toThrow("config is null");
+ // removeItem should be called
+ expect(AsyncStorage.removeItem).toHaveBeenCalledWith(RN_ASYNC_STORAGE_KEY);
+ });
+});
diff --git a/packages/react-native/src/lib/common/tests/file-upload.test.ts b/packages/react-native/src/lib/common/tests/file-upload.test.ts
new file mode 100644
index 0000000000..4000c69971
--- /dev/null
+++ b/packages/react-native/src/lib/common/tests/file-upload.test.ts
@@ -0,0 +1,186 @@
+// file-upload.test.ts
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { StorageAPI } from "@/lib/common/file-upload";
+import type { TUploadFileConfig } from "@/types/storage";
+
+// A global fetch mock so we can capture fetch calls.
+// Alternatively, use `vi.stubGlobal("fetch", ...)`.
+const fetchMock = vi.fn();
+global.fetch = fetchMock;
+
+const mockEnvironmentId = "dv46cywjt1fxkkempq7vwued";
+
+describe("StorageAPI", () => {
+ const APP_URL = "https://myapp.example";
+ const ENV_ID = mockEnvironmentId;
+
+ let storage: StorageAPI;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ storage = new StorageAPI(APP_URL, ENV_ID);
+ });
+
+ test("throws an error if file object is invalid", async () => {
+ // File missing "name", "type", or "base64"
+ await expect(storage.uploadFile({ type: "", name: "", base64: "" }, {})).rejects.toThrow(
+ "Invalid file object"
+ );
+ });
+
+ test("throws if first fetch (storage route) returns non-OK", async () => {
+ // We provide a valid file object
+ const file = { type: "image/png", name: "test.png", base64: "data:image/png;base64,abc" };
+
+ // First fetch returns not ok
+ fetchMock.mockResolvedValueOnce({
+ ok: false,
+ status: 400,
+ } as Response);
+
+ await expect(storage.uploadFile(file)).rejects.toThrow("Upload failed with status: 400");
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ expect(fetchMock).toHaveBeenCalledWith(
+ `${APP_URL}/api/v1/client/${ENV_ID}/storage`,
+ expect.objectContaining({
+ method: "POST",
+ })
+ );
+ });
+
+ test("throws if second fetch returns non-OK (local storage w/ signingData)", async () => {
+ // Suppose the first fetch is OK and returns JSON with signingData
+ const file = { type: "image/png", name: "test.png", base64: "data:image/png;base64,abc" };
+ fetchMock
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 10);
+ });
+
+ return {
+ data: {
+ signedUrl: "https://myapp.example/uploadLocal",
+ fileUrl: "https://myapp.example/files/test.png",
+ signingData: { signature: "xxx", timestamp: 1234, uuid: "abc" },
+ presignedFields: null,
+ updatedFileName: "test.png",
+ },
+ };
+ },
+ } as Response)
+ // second fetch fails
+ .mockResolvedValueOnce({
+ ok: false,
+ json: async () => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 10);
+ });
+
+ return { message: "File size exceeded your plan limit" };
+ },
+ } as Response);
+
+ await expect(storage.uploadFile(file)).rejects.toThrow("File size exceeded your plan limit");
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+
+ test("throws if second fetch returns non-OK (S3) containing 'EntityTooLarge'", async () => {
+ const file = { type: "image/png", name: "test.png", base64: "data:image/png;base64,abc" };
+
+ // First fetch response includes presignedFields => indicates S3 scenario
+ fetchMock
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 10);
+ });
+
+ return {
+ data: {
+ signedUrl: "https://some-s3-bucket/presigned",
+ fileUrl: "https://some-s3-bucket/test.png",
+ signingData: null, // means not local
+ presignedFields: {
+ key: "some-key",
+ policy: "base64policy",
+ },
+ updatedFileName: "test.png",
+ },
+ };
+ },
+ } as Response)
+ // second fetch fails with "EntityTooLarge"
+ .mockResolvedValueOnce({
+ ok: false,
+ text: async () => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 10);
+ });
+
+ return "Some error with EntityTooLarge text in it";
+ },
+ } as Response);
+
+ await expect(storage.uploadFile(file)).rejects.toThrow("File size exceeds the size limit for your plan");
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+
+ test("successful upload returns fileUrl", async () => {
+ const file = { type: "image/png", name: "test.png", base64: "data:image/png;base64,abc" };
+ const mockFileUrl = "https://myapp.example/files/test.png";
+
+ // First fetch => OK, returns JSON with 'signedUrl', 'fileUrl', etc.
+ fetchMock
+ .mockResolvedValueOnce({
+ ok: true,
+ json: async () => {
+ await new Promise((resolve) => {
+ setTimeout(resolve, 10);
+ });
+
+ return {
+ data: {
+ signedUrl: "https://myapp.example/uploadLocal",
+ fileUrl: mockFileUrl,
+ signingData: {
+ signature: "xxx",
+ timestamp: 1234,
+ uuid: "abc",
+ },
+ presignedFields: null,
+ updatedFileName: "test.png",
+ },
+ };
+ },
+ } as Response)
+ // second fetch => also OK
+ .mockResolvedValueOnce({
+ ok: true,
+ } as Response);
+
+ const url = await storage.uploadFile(file, {
+ allowedFileExtensions: [".png", ".jpg"],
+ surveyId: "survey_123",
+ } as TUploadFileConfig);
+
+ expect(url).toBe(mockFileUrl);
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+
+ // We can also check the first fetch request body
+ const firstCall = fetchMock.mock.calls[0];
+ expect(firstCall[0]).toBe(`${APP_URL}/api/v1/client/${ENV_ID}/storage`);
+
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-member-access -- we know it's a string
+ const bodyPayload = JSON.parse(firstCall[1].body as string);
+
+ expect(bodyPayload).toMatchObject({
+ fileName: "test.png",
+ fileType: "image/png",
+ allowedFileExtensions: [".png", ".jpg"],
+ surveyId: "survey_123",
+ });
+ });
+});
diff --git a/packages/react-native/src/lib/common/tests/initialize.test.ts b/packages/react-native/src/lib/common/tests/initialize.test.ts
new file mode 100644
index 0000000000..ec2202d0e6
--- /dev/null
+++ b/packages/react-native/src/lib/common/tests/initialize.test.ts
@@ -0,0 +1,366 @@
+// initialize.test.ts
+import AsyncStorage from "@react-native-async-storage/async-storage";
+import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { RNConfig, RN_ASYNC_STORAGE_KEY } from "@/lib/common/config";
+import {
+ addCleanupEventListeners,
+ addEventListeners,
+ removeAllEventListeners,
+} from "@/lib/common/event-listeners";
+import {
+ checkInitialized,
+ deinitalize,
+ handleErrorOnFirstInit,
+ init,
+ setIsInitialize,
+} from "@/lib/common/initialize";
+import { Logger } from "@/lib/common/logger";
+import { filterSurveys, isNowExpired } from "@/lib/common/utils";
+import { fetchEnvironmentState } from "@/lib/environment/state";
+import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state";
+import { sendUpdatesToBackend } from "@/lib/user/update";
+
+// 1) Mock AsyncStorage
+vi.mock("@react-native-async-storage/async-storage", () => ({
+ default: {
+ setItem: vi.fn(),
+ getItem: vi.fn(),
+ removeItem: vi.fn(),
+ },
+}));
+
+// 2) Mock RNConfig
+vi.mock("@/lib/common/config", () => ({
+ RN_ASYNC_STORAGE_KEY: "formbricks-react-native",
+ RNConfig: {
+ getInstance: vi.fn(() => ({
+ get: vi.fn(),
+ update: vi.fn(),
+ resetConfig: vi.fn(),
+ })),
+ },
+}));
+
+// 3) Mock logger
+vi.mock("@/lib/common/logger", () => ({
+ Logger: {
+ getInstance: vi.fn(() => ({
+ debug: vi.fn(),
+ error: vi.fn(),
+ })),
+ },
+}));
+
+// 4) Mock event-listeners
+vi.mock("@/lib/common/event-listeners", () => ({
+ addEventListeners: vi.fn(),
+ addCleanupEventListeners: vi.fn(),
+ removeAllEventListeners: vi.fn(),
+}));
+
+// 5) Mock fetchEnvironmentState
+vi.mock("@/lib/environment/state", () => ({
+ fetchEnvironmentState: vi.fn(),
+}));
+
+// 6) Mock filterSurveys
+vi.mock("@/lib/common/utils", async (importOriginal) => {
+ return {
+ ...(await importOriginal()),
+ filterSurveys: vi.fn(),
+ isNowExpired: vi.fn(),
+ };
+});
+
+// 7) Mock user/update
+vi.mock("@/lib/user/update", () => ({
+ sendUpdatesToBackend: vi.fn(),
+}));
+
+describe("initialize.ts", () => {
+ let getInstanceConfigMock: MockInstance<() => RNConfig>;
+ let getInstanceLoggerMock: MockInstance<() => Logger>;
+
+ const mockLogger = {
+ debug: vi.fn(),
+ error: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // By default, set isInitialize to false so we can test init logic from scratch
+ setIsInitialize(false);
+
+ getInstanceConfigMock = vi.spyOn(RNConfig, "getInstance");
+ getInstanceLoggerMock = vi.spyOn(Logger, "getInstance").mockReturnValue(mockLogger as unknown as Logger);
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ describe("init()", () => {
+ test("returns ok if already initialized", async () => {
+ getInstanceLoggerMock.mockReturnValue(mockLogger as unknown as Logger);
+ setIsInitialize(true);
+ const result = await init({ environmentId: "env_id", appUrl: "https://my.url" });
+ expect(result.ok).toBe(true);
+ expect(mockLogger.debug).toHaveBeenCalledWith("Already initialized, skipping initialization.");
+ });
+
+ test("fails if no environmentId is provided", async () => {
+ const result = await init({ environmentId: "", appUrl: "https://my.url" });
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.error.code).toBe("missing_field");
+ }
+ });
+
+ test("fails if no appUrl is provided", async () => {
+ const result = await init({ environmentId: "env_123", appUrl: "" });
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.error.code).toBe("missing_field");
+ }
+ });
+
+ test("skips init if existing config is in error state and not expired", async () => {
+ const mockConfig = {
+ get: vi.fn().mockReturnValue({
+ environmentId: "env_123",
+ appUrl: "https://my.url",
+ environment: {},
+ user: { data: {}, expiresAt: null },
+ status: { value: "error", expiresAt: new Date(Date.now() + 10000) },
+ }),
+ };
+
+ getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig);
+
+ (isNowExpired as unknown as Mock).mockReturnValue(true);
+
+ const result = await init({ environmentId: "env_123", appUrl: "https://my.url" });
+ expect(result.ok).toBe(true);
+ expect(mockLogger.debug).toHaveBeenCalledWith("Formbricks was set to an error state.");
+ expect(mockLogger.debug).toHaveBeenCalledWith("Error state is not expired, skipping initialization");
+ });
+
+ test("proceeds if error state is expired", async () => {
+ const mockConfig = {
+ get: vi.fn().mockReturnValue({
+ environmentId: "env_123",
+ appUrl: "https://my.url",
+ environment: {},
+ user: { data: {}, expiresAt: null },
+ status: { value: "error", expiresAt: new Date(Date.now() - 10000) }, // expired
+ }),
+ };
+
+ getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig);
+
+ const result = await init({ environmentId: "env_123", appUrl: "https://my.url" });
+ expect(result.ok).toBe(true);
+ expect(mockLogger.debug).toHaveBeenCalledWith("Formbricks was set to an error state.");
+ expect(mockLogger.debug).toHaveBeenCalledWith("Error state is expired. Continue with initialization.");
+ });
+
+ test("uses existing config if environmentId/appUrl match, checks for expiration sync", async () => {
+ const mockConfig = {
+ get: vi.fn().mockReturnValue({
+ environmentId: "env_123",
+ appUrl: "https://my.url",
+ environment: { expiresAt: new Date(Date.now() - 5000) }, // environment expired
+ user: {
+ data: { userId: "user_abc" },
+ expiresAt: new Date(Date.now() - 5000), // also expired
+ },
+ status: { value: "success", expiresAt: null },
+ }),
+ update: vi.fn(),
+ };
+
+ getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig);
+
+ (isNowExpired as unknown as Mock).mockReturnValue(true);
+
+ // Mock environment fetch success
+ (fetchEnvironmentState as unknown as Mock).mockResolvedValueOnce({
+ ok: true,
+ data: { data: { surveys: [] }, expiresAt: new Date(Date.now() + 60_000) },
+ });
+
+ // Mock sendUpdatesToBackend success
+ (sendUpdatesToBackend as unknown as Mock).mockResolvedValueOnce({
+ ok: true,
+ data: {
+ state: {
+ expiresAt: new Date(),
+ data: { userId: "user_abc", segments: [] },
+ },
+ },
+ });
+
+ (filterSurveys as unknown as Mock).mockReturnValueOnce([{ name: "S1" }, { name: "S2" }]);
+
+ const result = await init({ environmentId: "env_123", appUrl: "https://my.url" });
+ expect(result.ok).toBe(true);
+
+ // environmentState was fetched
+ expect(fetchEnvironmentState).toHaveBeenCalled();
+ // user state was updated
+ expect(sendUpdatesToBackend).toHaveBeenCalled();
+ // filterSurveys called
+ expect(filterSurveys).toHaveBeenCalled();
+ // config updated
+ expect(mockConfig.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- required for testing this object
+ user: expect.objectContaining({
+ data: { userId: "user_abc", segments: [] },
+ }),
+ filteredSurveys: [{ name: "S1" }, { name: "S2" }],
+ })
+ );
+ });
+
+ test("resets config if no valid config found, fetches environment, sets default user", async () => {
+ const mockConfig = {
+ get: () => {
+ throw new Error("no config found");
+ },
+ resetConfig: vi.fn(),
+ update: vi.fn(),
+ };
+
+ getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig);
+
+ (fetchEnvironmentState as unknown as Mock).mockResolvedValueOnce({
+ ok: true,
+ data: {
+ data: {
+ surveys: [{ name: "SurveyA" }],
+ expiresAt: new Date(Date.now() + 60000),
+ },
+ },
+ });
+
+ (filterSurveys as unknown as Mock).mockReturnValueOnce([{ name: "SurveyA" }]);
+
+ const result = await init({ environmentId: "envX", appUrl: "https://urlX" });
+ expect(result.ok).toBe(true);
+ expect(mockLogger.debug).toHaveBeenCalledWith("No existing configuration found.");
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ "No valid configuration found. Resetting config and creating new one."
+ );
+ expect(mockConfig.resetConfig).toHaveBeenCalled();
+ expect(fetchEnvironmentState).toHaveBeenCalled();
+ expect(mockConfig.update).toHaveBeenCalledWith({
+ appUrl: "https://urlX",
+ environmentId: "envX",
+ user: DEFAULT_USER_STATE_NO_USER_ID,
+ environment: {
+ data: {
+ surveys: [{ name: "SurveyA" }],
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- required for testing this object
+ expiresAt: expect.any(Date),
+ },
+ },
+ filteredSurveys: [{ name: "SurveyA" }],
+ });
+ });
+
+ test("calls handleErrorOnFirstInit if environment fetch fails initially", async () => {
+ const mockConfig = {
+ get: vi.fn().mockReturnValue(undefined),
+ update: vi.fn(),
+ resetConfig: vi.fn(),
+ };
+
+ getInstanceConfigMock.mockReturnValueOnce(mockConfig as unknown as RNConfig);
+
+ (fetchEnvironmentState as unknown as Mock).mockResolvedValueOnce({
+ ok: false,
+ error: { code: "forbidden", responseMessage: "No access" },
+ });
+
+ await expect(init({ environmentId: "envX", appUrl: "https://urlX" })).rejects.toThrow(
+ "Could not initialize formbricks"
+ );
+ });
+
+ test("adds event listeners and sets isInitialized", async () => {
+ const mockConfig = {
+ get: vi.fn().mockReturnValue({
+ environmentId: "env_abc",
+ appUrl: "https://test.app",
+ environment: {},
+ user: { data: {}, expiresAt: null },
+ status: { value: "success", expiresAt: null },
+ }),
+ update: vi.fn(),
+ };
+
+ getInstanceConfigMock.mockReturnValueOnce(mockConfig as unknown as RNConfig);
+
+ const result = await init({ environmentId: "env_abc", appUrl: "https://test.app" });
+ expect(result.ok).toBe(true);
+ expect(addEventListeners).toHaveBeenCalled();
+ expect(addCleanupEventListeners).toHaveBeenCalled();
+ });
+ });
+
+ describe("checkInitialized()", () => {
+ test("returns err if not initialized", () => {
+ const res = checkInitialized();
+ expect(res.ok).toBe(false);
+ if (!res.ok) {
+ expect(res.error.code).toBe("not_initialized");
+ }
+ });
+
+ test("returns ok if initialized", () => {
+ setIsInitialize(true);
+ const res = checkInitialized();
+ expect(res.ok).toBe(true);
+ });
+ });
+
+ describe("deinitalize()", () => {
+ test("resets user state to default and removes event listeners", async () => {
+ const mockConfig = {
+ get: vi.fn().mockReturnValue({
+ user: { data: { userId: "XYZ" } },
+ }),
+ update: vi.fn(),
+ };
+
+ getInstanceConfigMock.mockReturnValueOnce(mockConfig as unknown as RNConfig);
+
+ await deinitalize();
+
+ expect(mockConfig.update).toHaveBeenCalledWith(
+ expect.objectContaining({
+ user: DEFAULT_USER_STATE_NO_USER_ID,
+ })
+ );
+ expect(removeAllEventListeners).toHaveBeenCalled();
+ });
+ });
+
+ describe("handleErrorOnFirstInit()", () => {
+ test("stores error state in AsyncStorage, throws error", async () => {
+ // We import the function directly
+ const errorObj = { code: "forbidden", responseMessage: "No access" };
+
+ await expect(async () => {
+ await handleErrorOnFirstInit(errorObj);
+ }).rejects.toThrow("Could not initialize formbricks");
+
+ // AsyncStorage setItem should be called with the error config
+ expect(AsyncStorage.setItem).toHaveBeenCalledWith(
+ RN_ASYNC_STORAGE_KEY,
+ expect.stringContaining('"value":"error"')
+ );
+ });
+ });
+});
diff --git a/packages/react-native/src/lib/common/tests/logger.test.ts b/packages/react-native/src/lib/common/tests/logger.test.ts
new file mode 100644
index 0000000000..abedede805
--- /dev/null
+++ b/packages/react-native/src/lib/common/tests/logger.test.ts
@@ -0,0 +1,82 @@
+// logger.test.ts
+import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { Logger } from "@/lib/common/logger";
+
+// adjust import path as needed
+
+describe("Logger", () => {
+ let logger: Logger;
+ let consoleLogSpy: ReturnType;
+ let consoleErrorSpy: ReturnType;
+
+ beforeEach(() => {
+ logger = Logger.getInstance();
+
+ // Reset any existing singleton
+ logger.resetInstance();
+
+ logger = Logger.getInstance();
+
+ // Mock console so we don't actually log in test output
+ consoleLogSpy = vi.spyOn(console, "log").mockImplementation(() => {
+ return {
+ ok: true,
+ data: undefined,
+ };
+ });
+
+ consoleErrorSpy = vi.spyOn(console, "error").mockImplementation(() => {
+ return {
+ ok: true,
+ data: undefined,
+ };
+ });
+ });
+
+ afterEach(() => {
+ vi.restoreAllMocks();
+ });
+
+ test("getInstance() returns a singleton", () => {
+ const anotherLogger = Logger.getInstance();
+ expect(logger).toBe(anotherLogger);
+ });
+
+ test("default logLevel is 'error', so debug messages shouldn't appear", () => {
+ logger.debug("This is a debug log");
+ logger.error("This is an error log");
+
+ // debug should NOT be logged by default
+ expect(consoleLogSpy).not.toHaveBeenCalledWith(expect.stringContaining("This is a debug log"));
+ // error should be logged
+ expect(consoleErrorSpy).toHaveBeenCalledWith(expect.stringContaining("[ERROR] - This is an error log"));
+ });
+
+ test("configure to logLevel=debug => debug messages appear", () => {
+ logger.configure({ logLevel: "debug" });
+
+ logger.debug("Debug log after config");
+ logger.error("Error log after config");
+
+ // debug should now appear
+ expect(consoleLogSpy).toHaveBeenCalledWith(
+ expect.stringMatching(/🧱 Formbricks.*\[DEBUG\].*Debug log after config/)
+ );
+ // error should appear as well
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
+ expect.stringMatching(/🧱 Formbricks.*\[ERROR\].*Error log after config/)
+ );
+ });
+
+ test("logs have correct format including timestamp prefix", () => {
+ logger.configure({ logLevel: "debug" });
+ logger.debug("Some message");
+
+ // Check that the log includes 🧱 Formbricks, timestamp, [DEBUG], and the message
+ expect(consoleLogSpy).toHaveBeenCalledWith(
+ expect.stringMatching(
+ /^🧱 Formbricks - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}.\d{3}Z \[DEBUG\] - Some message$/
+ )
+ );
+ });
+});
diff --git a/packages/react-native/src/lib/common/tests/response-queue.test.ts b/packages/react-native/src/lib/common/tests/response-queue.test.ts
new file mode 100644
index 0000000000..ac5c695c52
--- /dev/null
+++ b/packages/react-native/src/lib/common/tests/response-queue.test.ts
@@ -0,0 +1,224 @@
+// response-queue.test.ts
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { type FormbricksAPI } from "@formbricks/api";
+import { ResponseQueue } from "@/lib/common/response-queue";
+import type { SurveyState } from "@/lib/survey/state";
+import type { TResponseUpdate } from "@/types/response";
+import {
+ mockAppUrl,
+ mockDisplayId,
+ mockEnvironmentId,
+ mockQuestionId,
+ mockResponseId,
+ mockSurveyId,
+ mockUserId,
+} from "./__mocks__/response-queue.mock";
+
+describe("ResponseQueue", () => {
+ let responseQueue: ResponseQueue;
+ let mockSurveyState: Partial;
+ let mockConfig: {
+ appUrl: string;
+ environmentId: string;
+ retryAttempts: number;
+ onResponseSendingFailed?: (resp: TResponseUpdate) => void;
+ onResponseSendingFinished?: () => void;
+ setSurveyState?: (state: SurveyState) => void;
+ };
+ let mockApi: FormbricksAPI;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ // 2) Setup a "fake" surveyState
+ mockSurveyState = {
+ responseId: null,
+ surveyId: mockSurveyId,
+ userId: mockUserId,
+ singleUseId: null,
+ displayId: mockDisplayId,
+ accumulateResponse: vi.fn(),
+ updateResponseId: vi.fn(),
+ };
+
+ // 3) Setup a config object
+ mockConfig = {
+ appUrl: mockAppUrl,
+ environmentId: mockEnvironmentId,
+ retryAttempts: 2,
+ onResponseSendingFailed: vi.fn(),
+ onResponseSendingFinished: vi.fn(),
+ setSurveyState: vi.fn(),
+ };
+
+ // Create the queue
+ mockApi = {
+ client: {
+ response: {
+ create: vi.fn(),
+ update: vi.fn(),
+ },
+ },
+ } as unknown as FormbricksAPI;
+
+ responseQueue = new ResponseQueue(mockConfig, mockSurveyState as SurveyState, mockApi);
+ });
+
+ test("add() accumulates response, updates setSurveyState, and calls processQueue()", () => {
+ // Spy on processQueue
+ const processQueueMock = vi.spyOn(responseQueue, "processQueue");
+
+ const update: TResponseUpdate = {
+ data: {
+ [mockQuestionId]: "test",
+ },
+ ttc: {
+ [mockQuestionId]: 1000,
+ },
+ finished: false,
+ };
+
+ // Call queue.add
+ responseQueue.add(update);
+
+ expect(mockSurveyState.accumulateResponse).toHaveBeenCalledWith(update);
+ expect(mockConfig.setSurveyState).toHaveBeenCalledTimes(1);
+ expect(processQueueMock).toHaveBeenCalledTimes(1);
+ });
+
+ test("processQueue does nothing if already in progress or queue is empty", async () => {
+ // Because processQueue is called in add()
+ // Let's set isRequestInProgress artificially and call processQueue directly:
+ const responseQueueWithIsRequestInProgress = responseQueue as unknown as {
+ isRequestInProgress: boolean;
+ };
+ responseQueueWithIsRequestInProgress.isRequestInProgress = true;
+ await responseQueue.processQueue();
+
+ // No changes, no error
+ expect(responseQueueWithIsRequestInProgress.isRequestInProgress).toBe(true);
+
+ // Now set queue empty, isRequestInProgress false
+ responseQueueWithIsRequestInProgress.isRequestInProgress = false;
+ await responseQueue.processQueue();
+ // still no error, but no action
+ // This just ensures we handle those conditions gracefully
+ });
+
+ test("when surveyState has no responseId, it calls create(...) and sets responseId on success", async () => {
+ const formbricksApiMock = vi.spyOn(mockApi.client.response, "create");
+
+ formbricksApiMock.mockResolvedValueOnce({
+ ok: true,
+ data: { id: mockResponseId },
+ });
+
+ // Add an item
+ const update: TResponseUpdate = {
+ data: { [mockQuestionId]: "test" },
+ ttc: { [mockQuestionId]: 1000 },
+ finished: false,
+ };
+
+ responseQueue.add(update);
+
+ // We need to wait for the queue to process
+ await responseQueue.processQueue();
+
+ // fake delay for the queue to process and get empty
+ await new Promise((r) => {
+ setTimeout(r, 100);
+ });
+
+ // Check create call
+ expect(formbricksApiMock).toHaveBeenCalledWith({
+ ...update,
+ surveyId: mockSurveyId,
+ userId: mockUserId,
+ singleUseId: null,
+ displayId: mockDisplayId,
+ data: {
+ [mockQuestionId]: "test",
+ },
+ });
+
+ // responseId is updated
+ expect(mockSurveyState.updateResponseId).toHaveBeenCalledWith(mockResponseId);
+
+ const responseQueueWithQueueArr = responseQueue as unknown as { queue: TResponseUpdate[] };
+ expect(responseQueueWithQueueArr.queue).toHaveLength(0);
+ });
+
+ test("when surveyState has a responseId, it calls update(...) and empties the queue", async () => {
+ mockSurveyState.responseId = mockResponseId;
+
+ const formbricksApiMock = vi.spyOn(mockApi.client.response, "update");
+
+ // Mock update => success
+ formbricksApiMock.mockResolvedValueOnce({
+ ok: true,
+ data: { id: mockResponseId },
+ });
+
+ const update: TResponseUpdate = {
+ data: { [mockQuestionId]: "test" },
+ ttc: { [mockQuestionId]: 1000 },
+ finished: false,
+ };
+
+ responseQueue.add(update);
+
+ await responseQueue.processQueue();
+
+ // fake delay for the queue to process and get empty
+ await new Promise((r) => {
+ setTimeout(r, 100);
+ });
+
+ expect(formbricksApiMock).toHaveBeenCalledWith({
+ ...update,
+ responseId: mockResponseId,
+ });
+
+ const responseQueueWithQueueArr = responseQueue as unknown as { queue: TResponseUpdate[] };
+ expect(responseQueueWithQueueArr.queue).toHaveLength(0);
+ });
+
+ test("retries up to retryAttempts if sendResponse fails", async () => {
+ // Force create to fail
+ const formbricksApiMock = vi.spyOn(mockApi.client.response, "create");
+ formbricksApiMock.mockRejectedValueOnce(new Error("Network error"));
+
+ const update: TResponseUpdate = {
+ data: { [mockQuestionId]: "fail" },
+ ttc: { [mockQuestionId]: 0 },
+ finished: false,
+ };
+
+ responseQueue.add(update);
+
+ await new Promise((r) => {
+ setTimeout(r, 1000);
+ });
+
+ await responseQueue.processQueue();
+
+ await new Promise((r) => {
+ setTimeout(r, 1000);
+ });
+
+ // It tries 2 times
+ expect(formbricksApiMock).toHaveBeenCalledTimes(2);
+ // Ultimately fails => item remains in queue
+ const responseQueueWithQueueArr = responseQueue as unknown as { queue: TResponseUpdate[] };
+ expect(responseQueueWithQueueArr.queue).toHaveLength(1);
+ });
+
+ test("updateSurveyState updates the surveyState reference", () => {
+ const newState = { responseId: mockResponseId } as SurveyState;
+ responseQueue.updateSurveyState(newState);
+
+ const responseQueueWithSurveyState = responseQueue as unknown as { surveyState: SurveyState };
+ expect(responseQueueWithSurveyState.surveyState).toBe(newState);
+ });
+});
diff --git a/packages/react-native/src/lib/common/tests/utils.test.ts b/packages/react-native/src/lib/common/tests/utils.test.ts
new file mode 100644
index 0000000000..36f87d37a7
--- /dev/null
+++ b/packages/react-native/src/lib/common/tests/utils.test.ts
@@ -0,0 +1,394 @@
+// utils.test.ts
+import { mockProjectId, mockSurveyId } from "@/lib/common/tests/__mocks__/config.mock";
+import {
+ diffInDays,
+ filterSurveys,
+ getDefaultLanguageCode,
+ getLanguageCode,
+ getStyling,
+ shouldDisplayBasedOnPercentage,
+ wrapThrowsAsync,
+} from "@/lib/common/utils";
+import type {
+ TEnvironmentState,
+ TEnvironmentStateProject,
+ TEnvironmentStateSurvey,
+ TSurveyStyling,
+ TUserState,
+} from "@/types/config";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+const mockSurveyId1 = "e3kxlpnzmdp84op9qzxl9olj";
+const mockSurveyId2 = "qo9rwjmms42hoy3k85fp8vgu";
+const mockSegmentId1 = "p6yrnz3s2tvoe5r0l28unq7k";
+const mockSegmentId2 = "wz43zrxeddhb1uo9cicustar";
+
+describe("utils.ts", () => {
+ // ---------------------------------------------------------------------------------
+ // diffInDays
+ // ---------------------------------------------------------------------------------
+ describe("diffInDays()", () => {
+ test("calculates correct day difference", () => {
+ const date1 = new Date("2023-01-01");
+ const date2 = new Date("2023-01-05");
+ expect(diffInDays(date1, date2)).toBe(4); // four days apart
+ });
+
+ test("handles negative differences (abs)", () => {
+ const date1 = new Date("2023-01-10");
+ const date2 = new Date("2023-01-05");
+ expect(diffInDays(date1, date2)).toBe(5);
+ });
+
+ test("0 if same day", () => {
+ const date = new Date("2023-01-01");
+ expect(diffInDays(date, date)).toBe(0);
+ });
+ });
+
+ // ---------------------------------------------------------------------------------
+ // wrapThrowsAsync
+ // ---------------------------------------------------------------------------------
+ describe("wrapThrowsAsync()", () => {
+ test("returns ok on success", async () => {
+ const fn = vi.fn(async (x: number) => {
+ await new Promise((r) => {
+ setTimeout(r, 10);
+ });
+ return x * 2;
+ });
+
+ const wrapped = wrapThrowsAsync(fn);
+
+ const result = await wrapped(5);
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.data).toBe(10);
+ }
+ });
+
+ test("returns err on error", async () => {
+ const fn = vi.fn(async () => {
+ await new Promise((r) => {
+ setTimeout(r, 10);
+ });
+ throw new Error("Something broke");
+ });
+ const wrapped = wrapThrowsAsync(fn);
+
+ const result = await wrapped();
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.error.message).toBe("Something broke");
+ }
+ });
+ });
+
+ // ---------------------------------------------------------------------------------
+ // filterSurveys
+ // ---------------------------------------------------------------------------------
+ describe("filterSurveys()", () => {
+ // We'll create a minimal environment state
+ let environment: TEnvironmentState;
+ let user: TUserState;
+ const baseSurvey: Partial = {
+ id: mockSurveyId,
+ displayOption: "displayOnce",
+ displayLimit: 1,
+ recontactDays: null,
+ languages: [],
+ };
+
+ beforeEach(() => {
+ environment = {
+ expiresAt: new Date(),
+ data: {
+ project: {
+ id: mockProjectId,
+ recontactDays: 7, // fallback if survey doesn't have it
+ clickOutsideClose: false,
+ darkOverlay: false,
+ placement: "bottomRight",
+ inAppSurveyBranding: true,
+ styling: { allowStyleOverwrite: false },
+ } as TEnvironmentStateProject,
+ surveys: [],
+ actionClasses: [],
+ },
+ };
+ user = {
+ expiresAt: null,
+ data: {
+ userId: null,
+ segments: [],
+ displays: [],
+ responses: [],
+ lastDisplayAt: null,
+ },
+ };
+ });
+
+ test("returns no surveys if user has no segments and userId is set", () => {
+ user.data.userId = "user_abc";
+ // environment has a single survey
+ environment.data.surveys = [
+ { ...baseSurvey, id: mockSurveyId1, segment: { id: mockSegmentId1 } } as TEnvironmentStateSurvey,
+ ];
+
+ const result = filterSurveys(environment, user);
+ expect(result).toEqual([]); // no segments => none pass
+ });
+
+ test("returns surveys if user has no userId but displayOnce and no displays yet", () => {
+ // userId is null => it won't segment filter
+ environment.data.surveys = [
+ { ...baseSurvey, id: mockSurveyId1, displayOption: "displayOnce" } as TEnvironmentStateSurvey,
+ ];
+
+ const result = filterSurveys(environment, user);
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe(mockSurveyId1);
+ });
+
+ test("skips surveys that already displayed if displayOnce is used", () => {
+ environment.data.surveys = [
+ { ...baseSurvey, id: mockSurveyId1, displayOption: "displayOnce" } as TEnvironmentStateSurvey,
+ ];
+ user.data.displays = [{ surveyId: mockSurveyId1, createdAt: new Date() }];
+
+ const result = filterSurveys(environment, user);
+ expect(result).toEqual([]);
+ });
+
+ test("skips surveys if user responded to them and displayOption=displayMultiple", () => {
+ environment.data.surveys = [
+ { ...baseSurvey, id: mockSurveyId1, displayOption: "displayMultiple" } as TEnvironmentStateSurvey,
+ ];
+ user.data.responses = [mockSurveyId1];
+
+ const result = filterSurveys(environment, user);
+ expect(result).toEqual([]);
+ });
+
+ test("handles displaySome logic with displayLimit", () => {
+ environment.data.surveys = [
+ {
+ ...baseSurvey,
+ id: mockSurveyId1,
+ displayOption: "displaySome",
+ displayLimit: 2,
+ } as TEnvironmentStateSurvey,
+ ];
+ // user has 1 display of s1
+ user.data.displays = [{ surveyId: mockSurveyId1, createdAt: new Date() }];
+
+ // No responses => so it's still allowed
+ const result = filterSurveys(environment, user);
+ expect(result).toHaveLength(1);
+ });
+
+ test("filters out surveys if recontactDays not met", () => {
+ // Suppose survey uses project fallback (7 days)
+ environment.data.surveys = [
+ { ...baseSurvey, id: mockSurveyId1, displayOption: "displayOnce" } as TEnvironmentStateSurvey,
+ ];
+ // user last displayAt is only 3 days ago
+ user.data.lastDisplayAt = new Date(Date.now() - 3 * 24 * 60 * 60 * 1000);
+
+ const result = filterSurveys(environment, user);
+ expect(result).toHaveLength(0);
+ });
+
+ test("passes surveys if enough days have passed since lastDisplayAt", () => {
+ // user last displayAt is 8 days ago
+ user.data.lastDisplayAt = new Date(Date.now() - 8 * 24 * 60 * 60 * 1000);
+
+ environment.data.surveys = [
+ {
+ ...baseSurvey,
+ id: mockSurveyId1,
+ displayOption: "respondMultiple",
+ recontactDays: null,
+ } as TEnvironmentStateSurvey,
+ ];
+ const result = filterSurveys(environment, user);
+ expect(result).toHaveLength(1);
+ });
+
+ test("filters by segment if userId is set and user has segments", () => {
+ user.data.userId = "user_abc";
+ user.data.segments = [mockSegmentId1];
+ environment.data.surveys = [
+ {
+ ...baseSurvey,
+ id: mockSurveyId1,
+ segment: { id: mockSegmentId1 },
+ displayOption: "respondMultiple",
+ } as TEnvironmentStateSurvey,
+ {
+ ...baseSurvey,
+ id: mockSurveyId2,
+ segment: { id: mockSegmentId2 },
+ displayOption: "respondMultiple",
+ } as TEnvironmentStateSurvey,
+ ];
+
+ const result = filterSurveys(environment, user);
+ // only the one that matches user's segment
+ expect(result).toHaveLength(1);
+ expect(result[0].id).toBe(mockSurveyId1);
+ });
+ });
+
+ // ---------------------------------------------------------------------------------
+ // getStyling
+ // ---------------------------------------------------------------------------------
+ describe("getStyling()", () => {
+ test("returns project styling if allowStyleOverwrite=false", () => {
+ const project = {
+ id: "p1",
+ styling: { allowStyleOverwrite: false, brandColor: { light: "#fff" } },
+ } as TEnvironmentStateProject;
+ const survey = {
+ styling: {
+ overwriteThemeStyling: true,
+ brandColor: { light: "#000" },
+ } as TSurveyStyling,
+ } as TEnvironmentStateSurvey;
+
+ const result = getStyling(project, survey);
+ // should get project styling
+ expect(result).toEqual(project.styling);
+ });
+
+ test("returns project styling if allowStyleOverwrite=true but survey overwriteThemeStyling=false", () => {
+ const project = {
+ id: "p1",
+ styling: { allowStyleOverwrite: true, brandColor: { light: "#fff" } },
+ } as TEnvironmentStateProject;
+ const survey = {
+ styling: {
+ overwriteThemeStyling: false,
+ brandColor: { light: "#000" },
+ } as TSurveyStyling,
+ } as TEnvironmentStateSurvey;
+
+ const result = getStyling(project, survey);
+ // should get project styling still
+ expect(result).toEqual(project.styling);
+ });
+
+ test("returns survey styling if allowStyleOverwrite=true and survey overwriteThemeStyling=true", () => {
+ const project = {
+ id: "p1",
+ styling: { allowStyleOverwrite: true, brandColor: { light: "#fff" } },
+ } as TEnvironmentStateProject;
+ const survey = {
+ styling: {
+ overwriteThemeStyling: true,
+ brandColor: { light: "#000" },
+ } as TSurveyStyling,
+ } as TEnvironmentStateSurvey;
+
+ const result = getStyling(project, survey);
+ expect(result).toEqual(survey.styling);
+ });
+ });
+
+ // ---------------------------------------------------------------------------------
+ // getDefaultLanguageCode
+ // ---------------------------------------------------------------------------------
+ describe("getDefaultLanguageCode()", () => {
+ test("returns code of the language if it is flagged default", () => {
+ const survey = {
+ languages: [
+ {
+ language: { code: "en" },
+ default: false,
+ enabled: true,
+ },
+ {
+ language: { code: "fr" },
+ default: true,
+ enabled: true,
+ },
+ ],
+ } as unknown as TEnvironmentStateSurvey;
+ expect(getDefaultLanguageCode(survey)).toBe("fr");
+ });
+
+ test("returns undefined if no default language found", () => {
+ const survey = {
+ languages: [
+ { language: { code: "en" }, default: false, enabled: true },
+ { language: { code: "fr" }, default: false, enabled: true },
+ ],
+ } as unknown as TEnvironmentStateSurvey;
+ expect(getDefaultLanguageCode(survey)).toBeUndefined();
+ });
+ });
+
+ // ---------------------------------------------------------------------------------
+ // getLanguageCode
+ // ---------------------------------------------------------------------------------
+ describe("getLanguageCode()", () => {
+ test("returns 'default' if no language param is passed", () => {
+ const survey = {
+ languages: [{ language: { code: "en" }, default: true, enabled: true }],
+ } as unknown as TEnvironmentStateSurvey;
+ const code = getLanguageCode(survey, undefined);
+ expect(code).toBe("default");
+ });
+
+ test("returns 'default' if the chosen language is the default one", () => {
+ const survey = {
+ languages: [
+ { language: { code: "en" }, default: true, enabled: true },
+ { language: { code: "fr" }, default: false, enabled: true },
+ ],
+ } as unknown as TEnvironmentStateSurvey;
+ const code = getLanguageCode(survey, "en");
+ expect(code).toBe("default");
+ });
+
+ test("returns undefined if language not found or disabled", () => {
+ const survey = {
+ languages: [
+ { language: { code: "en" }, default: true, enabled: true },
+ { language: { code: "fr" }, default: false, enabled: false },
+ ],
+ } as unknown as TEnvironmentStateSurvey;
+ const code = getLanguageCode(survey, "fr");
+ expect(code).toBeUndefined();
+ });
+
+ test("returns the language code if found and enabled", () => {
+ const survey = {
+ languages: [
+ { language: { code: "en", alias: "English" }, default: true, enabled: true },
+ { language: { code: "fr", alias: "fr-FR" }, default: false, enabled: true },
+ ],
+ } as unknown as TEnvironmentStateSurvey;
+ expect(getLanguageCode(survey, "fr")).toBe("fr");
+ expect(getLanguageCode(survey, "fr-FR")).toBe("fr");
+ });
+ });
+
+ // ---------------------------------------------------------------------------------
+ // shouldDisplayBasedOnPercentage
+ // ---------------------------------------------------------------------------------
+ describe("shouldDisplayBasedOnPercentage()", () => {
+ test("returns true if random number <= displayPercentage", () => {
+ // We'll mock Math.random to return something
+ const mockedRandom = vi.spyOn(Math, "random").mockReturnValue(0.2); // 0.2 => 20%
+ // displayPercentage = 30 => 30% => we should display
+ expect(shouldDisplayBasedOnPercentage(30)).toBe(true);
+
+ mockedRandom.mockReturnValue(0.5); // 50%
+ expect(shouldDisplayBasedOnPercentage(30)).toBe(false);
+
+ // restore
+ mockedRandom.mockRestore();
+ });
+ });
+});
diff --git a/packages/react-native/src/lib/common/utils.ts b/packages/react-native/src/lib/common/utils.ts
new file mode 100644
index 0000000000..912487d6ae
--- /dev/null
+++ b/packages/react-native/src/lib/common/utils.ts
@@ -0,0 +1,170 @@
+import type {
+ TEnvironmentState,
+ TEnvironmentStateProject,
+ TEnvironmentStateSurvey,
+ TProjectStyling,
+ TSurveyStyling,
+ TUserState,
+} from "@/types/config";
+import type { Result } from "@/types/error";
+
+// Helper function to calculate difference in days between two dates
+export const diffInDays = (date1: Date, date2: Date): number => {
+ const diffTime = Math.abs(date2.getTime() - date1.getTime());
+ return Math.floor(diffTime / (1000 * 60 * 60 * 24));
+};
+
+export const wrapThrowsAsync =
+ (fn: (...args: A) => Promise) =>
+ async (...args: A): Promise> => {
+ try {
+ return {
+ ok: true,
+ data: await fn(...args),
+ };
+ } catch (error) {
+ return {
+ ok: false,
+ error: error as Error,
+ };
+ }
+ };
+
+/**
+ * Filters surveys based on the displayOption, recontactDays, and segments
+ * @param environmentSate - The environment state
+ * @param userState - The user state
+ * @returns The filtered surveys
+ */
+
+// takes the environment and user state and returns the filtered surveys
+export const filterSurveys = (
+ environmentState: TEnvironmentState,
+ userState: TUserState
+): TEnvironmentStateSurvey[] => {
+ const { project, surveys } = environmentState.data;
+ const { displays, responses, lastDisplayAt, segments, userId } = userState.data;
+
+ // Function to filter surveys based on displayOption criteria
+ let filteredSurveys = surveys.filter((survey: TEnvironmentStateSurvey) => {
+ switch (survey.displayOption) {
+ case "respondMultiple":
+ return true;
+ case "displayOnce":
+ return displays.filter((display) => display.surveyId === survey.id).length === 0;
+ case "displayMultiple":
+ return responses.filter((surveyId) => surveyId === survey.id).length === 0;
+
+ case "displaySome":
+ if (survey.displayLimit === null) {
+ return true;
+ }
+
+ // Check if survey response exists, if so, stop here
+ if (responses.filter((surveyId) => surveyId === survey.id).length) {
+ return false;
+ }
+
+ // Otherwise, check if displays length is less than displayLimit
+ return displays.filter((display) => display.surveyId === survey.id).length < survey.displayLimit;
+
+ default:
+ throw Error("Invalid displayOption");
+ }
+ });
+
+ // filter surveys that meet the recontactDays criteria
+ filteredSurveys = filteredSurveys.filter((survey) => {
+ // if no survey was displayed yet, show the survey
+ if (!lastDisplayAt) {
+ return true;
+ }
+
+ // if survey has recontactDays, check if the last display was more than recontactDays ago
+ // The previous approach checked the last display for each survey which is why we still have a surveyId in the displays array.
+ // TODO: Remove the surveyId from the displays array
+ if (survey.recontactDays !== null) {
+ return diffInDays(new Date(), new Date(lastDisplayAt)) >= survey.recontactDays;
+ }
+
+ // use recontactDays of the project if survey does not have recontactDays
+ if (project.recontactDays) {
+ return diffInDays(new Date(), new Date(lastDisplayAt)) >= project.recontactDays;
+ }
+
+ // if no recontactDays is set, show the survey
+
+ return true;
+ });
+
+ if (!userId) {
+ return filteredSurveys;
+ }
+
+ if (!segments.length) {
+ return [];
+ }
+
+ // filter surveys based on segments
+ return filteredSurveys.filter((survey) => {
+ return survey.segment?.id && segments.includes(survey.segment.id);
+ });
+};
+
+export const getStyling = (
+ project: TEnvironmentStateProject,
+ survey: TEnvironmentStateSurvey
+): TProjectStyling | TSurveyStyling => {
+ // allow style overwrite is enabled from the project
+ if (project.styling.allowStyleOverwrite) {
+ // survey style overwrite is disabled
+ if (!survey.styling?.overwriteThemeStyling) {
+ return project.styling;
+ }
+
+ // survey style overwrite is enabled
+ return survey.styling;
+ }
+
+ // allow style overwrite is disabled from the project
+ return project.styling;
+};
+
+export const getDefaultLanguageCode = (survey: TEnvironmentStateSurvey): string | undefined => {
+ const defaultSurveyLanguage = survey.languages.find((surveyLanguage) => {
+ return surveyLanguage.default;
+ });
+ if (defaultSurveyLanguage) return defaultSurveyLanguage.language.code;
+};
+
+export const getLanguageCode = (survey: TEnvironmentStateSurvey, language?: string): string | undefined => {
+ const availableLanguageCodes = survey.languages.map((surveyLanguage) => surveyLanguage.language.code);
+ if (!language) return "default";
+
+ const selectedLanguage = survey.languages.find((surveyLanguage) => {
+ return (
+ surveyLanguage.language.code === language.toLowerCase() ||
+ surveyLanguage.language.alias?.toLowerCase() === language.toLowerCase()
+ );
+ });
+ if (selectedLanguage?.default) {
+ return "default";
+ }
+ if (
+ !selectedLanguage ||
+ !selectedLanguage.enabled ||
+ !availableLanguageCodes.includes(selectedLanguage.language.code)
+ ) {
+ return undefined;
+ }
+ return selectedLanguage.language.code;
+};
+
+export const shouldDisplayBasedOnPercentage = (displayPercentage: number): boolean => {
+ const randomNum = Math.floor(Math.random() * 10000) / 100;
+ return randomNum <= displayPercentage;
+};
+
+export const isNowExpired = (expirationDate: Date): boolean => {
+ return new Date() >= expirationDate;
+};
diff --git a/packages/react-native/src/lib/environment-state.ts b/packages/react-native/src/lib/environment-state.ts
deleted file mode 100644
index cdb8887661..0000000000
--- a/packages/react-native/src/lib/environment-state.ts
+++ /dev/null
@@ -1,130 +0,0 @@
-/* eslint-disable no-console -- logging required for error logging */
-// shared functions for environment and person state(s)
-import { type TJsEnvironmentState, type TJsEnvironmentSyncParams } from "@formbricks/types/js";
-import { err } from "../../../js-core/src/lib/errors";
-import { Logger } from "../../../js-core/src/lib/logger";
-import { filterSurveys } from "../../../js-core/src/lib/utils";
-import { RNConfig } from "./config";
-
-const appConfig = RNConfig.getInstance();
-const logger = Logger.getInstance();
-let environmentStateSyncIntervalId: number | null = null;
-
-/**
- * Fetch the environment state from the backend
- * @param apiHost - The API host
- * @param environmentId - The environment ID
- * @param noCache - Whether to skip the cache
- * @returns The environment state
- * @throws NetworkError
- */
-export const fetchEnvironmentState = async (
- { apiHost, environmentId }: TJsEnvironmentSyncParams,
- noCache = false
-): Promise => {
- const url = `${apiHost}/api/v1/client/${environmentId}/environment`;
-
- try {
- const fetchOptions: RequestInit = {};
-
- if (noCache) {
- fetchOptions.cache = "no-cache";
- logger.debug("No cache option set for sync");
- }
-
- const response = await fetch(url, fetchOptions);
-
- if (!response.ok) {
- const jsonRes = (await response.json()) as { message: string };
-
- const error = err({
- code: "network_error",
- status: response.status,
- message: "Error syncing with backend",
- url: new URL(url),
- responseMessage: jsonRes.message,
- });
-
- // eslint-disable-next-line @typescript-eslint/only-throw-error -- error.error is an Error object
- throw error.error;
- }
-
- const data = (await response.json()) as { data: TJsEnvironmentState["data"] };
- const { data: state } = data;
-
- return {
- data: { ...state },
- expiresAt: new Date(new Date().getTime() + 1000 * 60 * 30), // 30 minutes
- };
- } catch (e: unknown) {
- const errorTyped = e as { message?: string };
-
- const error = err({
- code: "network_error",
- message: errorTyped.message ?? "Error fetching the environment state",
- status: 500,
- url: new URL(url),
- responseMessage: errorTyped.message ?? "Unknown error",
- });
-
- // eslint-disable-next-line @typescript-eslint/only-throw-error -- error.error is an Error object
- throw error.error;
- }
-};
-
-/**
- * Add a listener to check if the environment state has expired with a certain interval
- */
-export const addEnvironmentStateExpiryCheckListener = (): void => {
- const updateInterval = 1000 * 60; // every minute
-
- if (environmentStateSyncIntervalId === null) {
- const intervalHandler = async (): Promise => {
- const expiresAt = appConfig.get().environmentState.expiresAt;
-
- try {
- // check if the environmentState has not expired yet
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- expiresAt is checked for null
- if (expiresAt && new Date(expiresAt) >= new Date()) {
- return;
- }
-
- logger.debug("Environment State has expired. Starting sync.");
-
- const personState = appConfig.get().personState;
- const environmentState = await fetchEnvironmentState(
- {
- apiHost: appConfig.get().apiHost,
- environmentId: appConfig.get().environmentId,
- },
- true
- );
-
- const filteredSurveys = filterSurveys(environmentState, personState);
-
- appConfig.update({
- ...appConfig.get(),
- environmentState,
- filteredSurveys,
- });
- } catch (e) {
- console.error(`Error during expiry check: ${e as string}`);
- logger.debug("Extending config and try again later.");
- const existingConfig = appConfig.get();
- appConfig.update(existingConfig);
- }
- };
-
- environmentStateSyncIntervalId = setInterval(
- () => void intervalHandler(),
- updateInterval
- ) as unknown as number;
- }
-};
-
-export const clearEnvironmentStateExpiryCheckListener = (): void => {
- if (environmentStateSyncIntervalId) {
- clearInterval(environmentStateSyncIntervalId);
- environmentStateSyncIntervalId = null;
- }
-};
diff --git a/packages/react-native/src/lib/environment/state.ts b/packages/react-native/src/lib/environment/state.ts
new file mode 100644
index 0000000000..f62349df67
--- /dev/null
+++ b/packages/react-native/src/lib/environment/state.ts
@@ -0,0 +1,118 @@
+/* eslint-disable no-console -- logging required for error logging */
+import { FormbricksAPI } from "@formbricks/api";
+import { RNConfig } from "@/lib/common/config";
+import { Logger } from "@/lib/common/logger";
+import { filterSurveys } from "@/lib/common/utils";
+import type { TConfigInput, TEnvironmentState } from "@/types/config";
+import { type ApiErrorResponse, type Result, err, ok } from "@/types/error";
+
+let environmentStateSyncIntervalId: number | null = null;
+
+/**
+ * Fetch the environment state from the backend
+ * @param appUrl - The app URL
+ * @param environmentId - The environment ID
+ * @returns The environment state
+ * @throws NetworkError
+ */
+export const fetchEnvironmentState = async ({
+ appUrl,
+ environmentId,
+}: TConfigInput): Promise> => {
+ const url = `${appUrl}/api/v1/client/${environmentId}/environment`;
+ const api = new FormbricksAPI({ apiHost: appUrl, environmentId });
+
+ try {
+ const response = await api.client.environment.getState();
+
+ if (!response.ok) {
+ return err({
+ code: response.error.code,
+ status: response.error.status,
+ message: "Error syncing with backend",
+ url: new URL(url),
+ responseMessage: response.error.message,
+ });
+ }
+
+ return ok(response.data) as Result;
+ } catch (e: unknown) {
+ const errorTyped = e as ApiErrorResponse;
+ return err({
+ code: "network_error",
+ message: errorTyped.message,
+ status: 500,
+ url: new URL(url),
+ responseMessage: errorTyped.responseMessage ?? "Network error",
+ });
+ }
+};
+
+/**
+ * Add a listener to check if the environment state has expired with a certain interval
+ */
+export const addEnvironmentStateExpiryCheckListener = (): void => {
+ const appConfig = RNConfig.getInstance();
+ const logger = Logger.getInstance();
+
+ const updateInterval = 1000 * 60; // every minute
+
+ if (environmentStateSyncIntervalId === null) {
+ const intervalHandler = async (): Promise => {
+ const expiresAt = appConfig.get().environment.expiresAt;
+
+ try {
+ // check if the environmentState has not expired yet
+ // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- expiresAt is checked for null
+ if (expiresAt && new Date(expiresAt) >= new Date()) {
+ return;
+ }
+
+ logger.debug("Environment State has expired. Starting sync.");
+
+ const personState = appConfig.get().user;
+ const environmentState = await fetchEnvironmentState({
+ appUrl: appConfig.get().appUrl,
+ environmentId: appConfig.get().environmentId,
+ });
+
+ if (environmentState.ok) {
+ const { data: state } = environmentState;
+ const filteredSurveys = filterSurveys(state, personState);
+
+ appConfig.update({
+ ...appConfig.get(),
+ environment: state,
+ filteredSurveys,
+ });
+ } else {
+ // eslint-disable-next-line @typescript-eslint/only-throw-error -- error is an ApiErrorResponse
+ throw environmentState.error;
+ }
+ } catch (e) {
+ console.error(`Error during expiry check: `, e);
+ logger.debug("Extending config and try again later.");
+ const existingConfig = appConfig.get();
+ appConfig.update({
+ ...existingConfig,
+ environment: {
+ ...existingConfig.environment,
+ expiresAt: new Date(new Date().getTime() + 1000 * 60 * 30), // 30 minutes
+ },
+ });
+ }
+ };
+
+ environmentStateSyncIntervalId = setInterval(
+ () => void intervalHandler(),
+ updateInterval
+ ) as unknown as number;
+ }
+};
+
+export const clearEnvironmentStateExpiryCheckListener = (): void => {
+ if (environmentStateSyncIntervalId) {
+ clearInterval(environmentStateSyncIntervalId);
+ environmentStateSyncIntervalId = null;
+ }
+};
diff --git a/packages/react-native/src/lib/environment/tests/state.test.ts b/packages/react-native/src/lib/environment/tests/state.test.ts
new file mode 100644
index 0000000000..04807d866c
--- /dev/null
+++ b/packages/react-native/src/lib/environment/tests/state.test.ts
@@ -0,0 +1,306 @@
+// state.test.ts
+import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { FormbricksAPI } from "@formbricks/api";
+import { RNConfig } from "@/lib/common/config";
+import { Logger } from "@/lib/common/logger";
+import { filterSurveys } from "@/lib/common/utils";
+import {
+ addEnvironmentStateExpiryCheckListener,
+ clearEnvironmentStateExpiryCheckListener,
+ fetchEnvironmentState,
+} from "@/lib/environment/state";
+import type { TEnvironmentState } from "@/types/config";
+
+// Mock the FormbricksAPI so we can control environment.getState
+vi.mock("@formbricks/api", () => ({
+ FormbricksAPI: vi.fn().mockImplementation(() => ({
+ client: {
+ environment: {
+ getState: vi.fn(),
+ },
+ },
+ })),
+}));
+
+// Mock logger (so we don’t spam console)
+vi.mock("@/lib/common/logger", () => ({
+ Logger: {
+ getInstance: vi.fn(() => {
+ return {
+ debug: vi.fn(),
+ error: vi.fn(),
+ };
+ }),
+ },
+}));
+
+// Mock filterSurveys
+vi.mock("@/lib/common/utils", () => ({
+ filterSurveys: vi.fn(),
+}));
+
+// Mock RNConfig
+vi.mock("@/lib/common/config", () => {
+ return {
+ RN_ASYNC_STORAGE_KEY: "formbricks-react-native",
+ RNConfig: {
+ getInstance: vi.fn(() => ({
+ get: vi.fn(),
+ update: vi.fn(),
+ })),
+ },
+ };
+});
+
+describe("environment/state.ts", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ afterEach(() => {
+ // Use real timers so we don't pollute subsequent test code
+ vi.useRealTimers();
+ });
+
+ describe("fetchEnvironmentState()", () => {
+ test("returns ok(...) with environment state", async () => {
+ // Setup mock
+ (FormbricksAPI as unknown as Mock).mockImplementationOnce(() => {
+ return {
+ client: {
+ environment: {
+ getState: vi.fn().mockResolvedValue({
+ ok: true,
+ data: { data: { foo: "bar" }, expiresAt: new Date(Date.now() + 1000 * 60 * 30) },
+ }),
+ },
+ },
+ };
+ });
+
+ const result = await fetchEnvironmentState({
+ appUrl: "https://fake.host",
+ environmentId: "env_123",
+ });
+
+ expect(result.ok).toBe(true);
+
+ if (result.ok) {
+ const val: TEnvironmentState = result.data;
+ expect(val.data).toEqual({ foo: "bar" });
+ expect(val.expiresAt).toBeInstanceOf(Date);
+ }
+ });
+
+ test("returns err(...) if environment.getState is not ok", async () => {
+ const mockError = { code: "forbidden", status: 403, message: "Access denied" };
+
+ (FormbricksAPI as unknown as Mock).mockImplementationOnce(() => {
+ return {
+ client: {
+ environment: {
+ getState: vi.fn().mockResolvedValue({
+ ok: false,
+ error: mockError,
+ }),
+ },
+ },
+ };
+ });
+
+ const result = await fetchEnvironmentState({
+ appUrl: "https://fake.host",
+ environmentId: "env_123",
+ });
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.error.code).toBe(mockError.code);
+ expect(result.error.status).toBe(mockError.status);
+ expect(result.error.responseMessage).toBe(mockError.message);
+ }
+ });
+
+ test("returns err(...) on network error catch", async () => {
+ const mockNetworkError = {
+ code: "network_error",
+ message: "Timeout",
+ responseMessage: "Network fail",
+ };
+
+ (FormbricksAPI as unknown as Mock).mockImplementationOnce(() => {
+ return {
+ client: {
+ environment: {
+ getState: vi.fn().mockRejectedValue(mockNetworkError),
+ },
+ },
+ };
+ });
+
+ const result = await fetchEnvironmentState({
+ appUrl: "https://fake.host",
+ environmentId: "env_123",
+ });
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.error.code).toBe(mockNetworkError.code);
+ expect(result.error.message).toBe(mockNetworkError.message);
+ expect(result.error.responseMessage).toBe(mockNetworkError.responseMessage);
+ }
+ });
+ });
+
+ describe("addEnvironmentStateExpiryCheckListener()", () => {
+ let mockRNConfig: MockInstance<() => RNConfig>;
+ let mockLoggerInstance: MockInstance<() => Logger>;
+
+ const mockLogger = {
+ debug: vi.fn(),
+ error: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers();
+
+ mockRNConfig = vi.spyOn(RNConfig, "getInstance");
+ const mockConfig = {
+ get: vi.fn().mockReturnValue({
+ environment: {
+ expiresAt: new Date(Date.now() + 60_000), // Not expired for now
+ },
+ user: {},
+ environmentId: "env_123",
+ appUrl: "https://fake.host",
+ }),
+ };
+
+ mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig);
+
+ mockLoggerInstance = vi.spyOn(Logger, "getInstance");
+ mockLoggerInstance.mockReturnValue(mockLogger as unknown as Logger);
+ });
+
+ afterEach(() => {
+ clearEnvironmentStateExpiryCheckListener(); // clear after each test
+ });
+
+ test("starts interval check and updates state when expired", async () => {
+ const mockConfig = {
+ get: vi.fn().mockReturnValue({
+ environment: {
+ expiresAt: new Date(Date.now() - 1000).toISOString(), // expired
+ },
+ appUrl: "https://test.com",
+ environmentId: "env_123",
+ user: { data: {} },
+ }),
+ update: vi.fn(),
+ };
+
+ const mockNewState = {
+ data: {
+ expiresAt: new Date(Date.now() + 1000 * 60 * 30).toISOString(),
+ },
+ };
+
+ mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig);
+
+ (FormbricksAPI as Mock).mockImplementation(() => ({
+ client: {
+ environment: {
+ getState: vi.fn().mockResolvedValue({
+ ok: true,
+ data: mockNewState,
+ }),
+ },
+ },
+ }));
+
+ (filterSurveys as Mock).mockReturnValue([]);
+
+ // Add listener
+ addEnvironmentStateExpiryCheckListener();
+
+ // Fast-forward time
+ await vi.advanceTimersByTimeAsync(1000 * 60);
+
+ // Verify the update was called
+ expect(mockConfig.update).toHaveBeenCalled();
+ });
+
+ test("extends expiry on error", async () => {
+ const mockConfig = {
+ get: vi.fn().mockReturnValue({
+ environment: {
+ expiresAt: new Date(Date.now() - 1000).toISOString(),
+ },
+ appUrl: "https://test.com",
+ environmentId: "env_123",
+ }),
+ update: vi.fn(),
+ };
+
+ mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig);
+
+ // Mock API to throw an error
+ (FormbricksAPI as Mock).mockImplementation(() => ({
+ client: {
+ environment: {
+ getState: vi.fn().mockRejectedValue(new Error("Network error")),
+ },
+ },
+ }));
+
+ addEnvironmentStateExpiryCheckListener();
+
+ // Fast-forward time
+ await vi.advanceTimersByTimeAsync(1000 * 60);
+
+ // Verify the config was updated with extended expiry
+ expect(mockConfig.update).toHaveBeenCalled();
+ });
+
+ test("does not fetch new state if not expired", async () => {
+ const futureDate = new Date(Date.now() + 1000 * 60 * 60); // 1 hour in future
+ const mockConfig = {
+ get: vi.fn().mockReturnValue({
+ environment: {
+ expiresAt: futureDate.toISOString(),
+ },
+ appUrl: "https://test.com",
+ environmentId: "env_123",
+ }),
+ update: vi.fn(),
+ };
+
+ mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig);
+
+ const apiMock = vi.fn().mockImplementation(() => ({
+ client: {
+ environment: {
+ getState: vi.fn(),
+ },
+ },
+ }));
+
+ (FormbricksAPI as Mock).mockImplementation(apiMock);
+
+ addEnvironmentStateExpiryCheckListener();
+
+ // Fast-forward time by less than expiry
+ await vi.advanceTimersByTimeAsync(1000 * 60);
+
+ expect(mockConfig.update).not.toHaveBeenCalled();
+ });
+
+ test("clears interval when clearEnvironmentStateExpiryCheckListener is called", () => {
+ const clearIntervalSpy = vi.spyOn(global, "clearInterval");
+
+ addEnvironmentStateExpiryCheckListener();
+ clearEnvironmentStateExpiryCheckListener();
+
+ expect(clearIntervalSpy).toHaveBeenCalled();
+ });
+ });
+});
diff --git a/packages/react-native/src/lib/index.ts b/packages/react-native/src/lib/index.ts
deleted file mode 100644
index 096be58cea..0000000000
--- a/packages/react-native/src/lib/index.ts
+++ /dev/null
@@ -1,21 +0,0 @@
-import { type TJsConfigInput } from "@formbricks/types/js";
-import { ErrorHandler } from "../../../js-core/src/lib/errors";
-import { Logger } from "../../../js-core/src/lib/logger";
-import { trackCodeAction } from "./actions";
-import { CommandQueue } from "./command-queue";
-import { initialize } from "./initialize";
-
-const logger = Logger.getInstance();
-logger.debug("Create command queue");
-const queue = new CommandQueue();
-
-export const init = async (initConfig: TJsConfigInput): Promise => {
- ErrorHandler.init(initConfig.errorHandler);
- queue.add(initialize, false, initConfig);
- await queue.wait();
-};
-
-export const track = async (name: string, properties = {}): Promise => {
- queue.add(trackCodeAction, true, name, properties);
- await queue.wait();
-};
diff --git a/packages/react-native/src/lib/initialize.ts b/packages/react-native/src/lib/initialize.ts
deleted file mode 100644
index c7d8142b2c..0000000000
--- a/packages/react-native/src/lib/initialize.ts
+++ /dev/null
@@ -1,274 +0,0 @@
-import AsyncStorage from "@react-native-async-storage/async-storage";
-import { type TAttributes } from "@formbricks/types/attributes";
-import { wrapThrowsAsync } from "@formbricks/types/error-handlers";
-import { type TJsConfig, type TJsConfigInput } from "@formbricks/types/js";
-import { RN_ASYNC_STORAGE_KEY } from "../../../js-core/src/lib/constants";
-import {
- ErrorHandler,
- type MissingFieldError,
- type MissingPersonError,
- type NetworkError,
- type NotInitializedError,
- type Result,
- err,
- okVoid,
-} from "../../../js-core/src/lib/errors";
-import { Logger } from "../../../js-core/src/lib/logger";
-import { filterSurveys } from "../../../js-core/src/lib/utils";
-import { trackAction } from "./actions";
-import { updateAttributes } from "./attributes";
-import { RNConfig } from "./config";
-import { fetchEnvironmentState } from "./environment-state";
-import { addCleanupEventListeners, addEventListeners, removeAllEventListeners } from "./event-listeners";
-import { DEFAULT_PERSON_STATE_NO_USER_ID, fetchPersonState } from "./person-state";
-
-let isInitialized = false;
-const appConfig = RNConfig.getInstance();
-const logger = Logger.getInstance();
-
-export const setIsInitialize = (state: boolean): void => {
- isInitialized = state;
-};
-
-export const initialize = async (
- configInput: TJsConfigInput
-): Promise> => {
- if (isInitialized) {
- logger.debug("Already initialized, skipping initialization.");
- return okVoid();
- }
-
- let existingConfig: TJsConfig | undefined;
- try {
- existingConfig = appConfig.get();
- logger.debug("Found existing configuration.");
- } catch {
- logger.debug("No existing configuration found.");
- }
-
- // formbricks is in error state, skip initialization
- if (existingConfig?.status.value === "error") {
- logger.debug("Formbricks was set to an error state.");
-
- const expiresAt = existingConfig.status.expiresAt;
-
- if (expiresAt && new Date(expiresAt) > new Date()) {
- logger.debug("Error state is not expired, skipping initialization");
- return okVoid();
- }
- logger.debug("Error state is expired. Continue with initialization.");
- }
-
- ErrorHandler.getInstance().printStatus();
-
- logger.debug("Start initialize");
-
- if (!configInput.environmentId) {
- logger.debug("No environmentId provided");
- return err({
- code: "missing_field",
- field: "environmentId",
- });
- }
-
- if (!configInput.apiHost) {
- logger.debug("No apiHost provided");
-
- return err({
- code: "missing_field",
- field: "apiHost",
- });
- }
-
- if (
- existingConfig?.environmentState &&
- existingConfig.environmentId === configInput.environmentId &&
- existingConfig.apiHost === configInput.apiHost
- ) {
- logger.debug("Configuration fits init parameters.");
- let isEnvironmentStateExpired = false;
- let isPersonStateExpired = false;
-
- if (new Date(existingConfig.environmentState.expiresAt) < new Date()) {
- logger.debug("Environment state expired. Syncing.");
- isEnvironmentStateExpired = true;
- }
-
- if (
- configInput.userId &&
- // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- personState could be null
- (existingConfig.personState === null ||
- (existingConfig.personState.expiresAt && new Date(existingConfig.personState.expiresAt) < new Date()))
- ) {
- logger.debug("Person state needs syncing - either null or expired");
- isPersonStateExpired = true;
- }
-
- try {
- // fetch the environment state (if expired)
- const environmentState = isEnvironmentStateExpired
- ? await fetchEnvironmentState({
- apiHost: configInput.apiHost,
- environmentId: configInput.environmentId,
- })
- : existingConfig.environmentState;
-
- // fetch the person state (if expired)
-
- let { personState } = existingConfig;
-
- if (isPersonStateExpired) {
- if (configInput.userId) {
- personState = await fetchPersonState({
- apiHost: configInput.apiHost,
- environmentId: configInput.environmentId,
- userId: configInput.userId,
- });
- } else {
- personState = DEFAULT_PERSON_STATE_NO_USER_ID;
- }
- }
-
- // filter the environment state wrt the person state
- const filteredSurveys = filterSurveys(environmentState, personState);
-
- // update the appConfig with the new filtered surveys
- appConfig.update({
- ...existingConfig,
- environmentState,
- personState,
- filteredSurveys,
- attributes: configInput.attributes ?? {},
- });
-
- const surveyNames = filteredSurveys.map((s) => s.name);
- logger.debug(`Fetched ${surveyNames.length.toString()} surveys during sync: ${surveyNames.join(", ")}`);
- } catch {
- logger.debug("Error during sync. Please try again.");
- }
- } else {
- logger.debug("No valid configuration found. Resetting config and creating new one.");
- void appConfig.resetConfig();
- logger.debug("Syncing.");
-
- try {
- const environmentState = await fetchEnvironmentState(
- {
- apiHost: configInput.apiHost,
- environmentId: configInput.environmentId,
- },
- false
- );
-
- const personState = configInput.userId
- ? await fetchPersonState(
- {
- apiHost: configInput.apiHost,
- environmentId: configInput.environmentId,
- userId: configInput.userId,
- },
- false
- )
- : DEFAULT_PERSON_STATE_NO_USER_ID;
-
- const filteredSurveys = filterSurveys(environmentState, personState);
-
- let updatedAttributes: TAttributes | null = null;
- if (configInput.attributes) {
- if (configInput.userId) {
- const res = await updateAttributes(
- configInput.apiHost,
- configInput.environmentId,
- configInput.userId,
- configInput.attributes
- );
-
- if (!res.ok) {
- if (res.error.code === "forbidden") {
- logger.error(`Authorization error: ${res.error.responseMessage ?? ""}`);
- }
- return err(res.error) as unknown as Result<
- void,
- MissingFieldError | NetworkError | MissingPersonError
- >;
- }
-
- updatedAttributes = res.value;
- } else {
- updatedAttributes = { ...configInput.attributes };
- }
- }
-
- appConfig.update({
- apiHost: configInput.apiHost,
- environmentId: configInput.environmentId,
- personState,
- environmentState,
- filteredSurveys,
- attributes: updatedAttributes ?? {},
- });
- } catch (e) {
- await handleErrorOnFirstInit(e as { code: string; responseMessage: string });
- }
-
- // and track the new session event
- trackAction("New Session");
- }
-
- logger.debug("Adding event listeners");
- addEventListeners();
- addCleanupEventListeners();
-
- setIsInitialize(true);
- logger.debug("Initialized");
-
- // check page url if initialized after page load
- return okVoid();
-};
-
-export const checkInitialized = (): Result => {
- logger.debug("Check if initialized");
-
- if (!isInitialized || !ErrorHandler.initialized) {
- return err({
- code: "not_initialized",
- message: "Formbricks not initialized. Call initialize() first.",
- });
- }
-
- return okVoid();
-};
-
-export const deinitalize = async (): Promise => {
- logger.debug("Deinitializing");
- await appConfig.resetConfig();
- setIsInitialize(false);
- removeAllEventListeners();
-};
-
-export const handleErrorOnFirstInit = async (e: {
- code: string;
- responseMessage: string;
-}): Promise => {
- if (e.code === "forbidden") {
- logger.error(`Authorization error: ${e.responseMessage}`);
- } else {
- logger.error(
- `Error during first initialization: ${e.code} - ${e.responseMessage}. Please try again later.`
- );
- }
-
- // put formbricks in error state (by creating a new config) and throw error
- const initialErrorConfig: Partial = {
- status: {
- value: "error",
- expiresAt: new Date(new Date().getTime() + 10 * 60000), // 10 minutes in the future
- },
- };
-
- await wrapThrowsAsync(async () => {
- await AsyncStorage.setItem(RN_ASYNC_STORAGE_KEY, JSON.stringify(initialErrorConfig));
- })();
-
- throw new Error("Could not initialize formbricks");
-};
diff --git a/packages/react-native/src/lib/person-state.ts b/packages/react-native/src/lib/person-state.ts
deleted file mode 100644
index a890dc98e6..0000000000
--- a/packages/react-native/src/lib/person-state.ts
+++ /dev/null
@@ -1,135 +0,0 @@
-import { type TJsPersonState, type TJsPersonSyncParams } from "@formbricks/types/js";
-import { err } from "../../../js-core/src/lib/errors";
-import { Logger } from "../../../js-core/src/lib/logger";
-import { RNConfig } from "./config";
-
-const config = RNConfig.getInstance();
-const logger = Logger.getInstance();
-let personStateSyncIntervalId: number | null = null;
-
-export const DEFAULT_PERSON_STATE_NO_USER_ID: TJsPersonState = {
- expiresAt: null,
- data: {
- userId: null,
- segments: [],
- displays: [],
- responses: [],
- lastDisplayAt: null,
- },
-} as const;
-
-/**
- * Fetch the person state from the backend
- * @param apiHost - The API host
- * @param environmentId - The environment ID
- * @param userId - The user ID
- * @param noCache - Whether to skip the cache
- * @returns The person state
- * @throws NetworkError
- */
-export const fetchPersonState = async (
- { apiHost, environmentId, userId }: TJsPersonSyncParams,
- noCache = false
-): Promise => {
- const url = `${apiHost}/api/v1/client/${environmentId}/identify/contacts/${userId}`;
-
- try {
- const fetchOptions: RequestInit = {};
-
- if (noCache) {
- fetchOptions.cache = "no-cache";
- logger.debug("No cache option set for sync");
- }
-
- const response = await fetch(url, fetchOptions);
-
- if (!response.ok) {
- const jsonRes = (await response.json()) as { code: string; message: string };
-
- const error = err({
- code: jsonRes.code === "forbidden" ? "forbidden" : "network_error",
- status: response.status,
- message: "Error syncing with backend",
- url: new URL(url),
- responseMessage: jsonRes.message,
- });
-
- // eslint-disable-next-line @typescript-eslint/only-throw-error -- error.error is an Error object
- throw error.error;
- }
-
- const data = (await response.json()) as { data: TJsPersonState["data"] };
- const { data: state } = data;
-
- const defaultPersonState: TJsPersonState = {
- expiresAt: new Date(new Date().getTime() + 1000 * 60 * 30), // 30 minutes
- data: {
- userId,
- segments: [],
- displays: [],
- responses: [],
- lastDisplayAt: null,
- },
- };
-
- if (!Object.keys(state).length) {
- return defaultPersonState;
- }
-
- return {
- data: { ...state },
- expiresAt: new Date(new Date().getTime() + 1000 * 60 * 30), // 30 minutes
- };
- } catch (e: unknown) {
- const errorTyped = e as { message?: string };
-
- const error = err({
- code: "network_error",
- message: errorTyped.message ?? "Error fetching the person state",
- status: 500,
- url: new URL(url),
- responseMessage: errorTyped.message ?? "Unknown error",
- });
-
- // eslint-disable-next-line @typescript-eslint/only-throw-error -- error.error is an Error object
- throw error.error;
- }
-};
-
-/**
- * Add a listener to check if the person state has expired with a certain interval
- */
-export const addPersonStateExpiryCheckListener = (): void => {
- const updateInterval = 1000 * 60; // every 60 seconds
-
- if (personStateSyncIntervalId === null) {
- const intervalHandler = (): void => {
- const userId = config.get().personState.data.userId;
-
- if (!userId) {
- return;
- }
-
- // extend the personState validity by 30 minutes:
- config.update({
- ...config.get(),
- personState: {
- ...config.get().personState,
- expiresAt: new Date(new Date().getTime() + 1000 * 60 * 30), // 30 minutes
- },
- });
- };
-
- personStateSyncIntervalId = setInterval(intervalHandler, updateInterval) as unknown as number;
- }
-};
-
-/**
- * Clear the person state expiry check listener
- */
-export const clearPersonStateExpiryCheckListener = (): void => {
- if (personStateSyncIntervalId) {
- clearInterval(personStateSyncIntervalId);
- personStateSyncIntervalId = null;
- }
-};
diff --git a/packages/react-native/src/lib/person.ts b/packages/react-native/src/lib/person.ts
deleted file mode 100644
index 1a81da368e..0000000000
--- a/packages/react-native/src/lib/person.ts
+++ /dev/null
@@ -1,30 +0,0 @@
-import { type NetworkError, type Result, err, okVoid } from "../../../js-core/src/lib/errors";
-import { Logger } from "../../../js-core/src/lib/logger";
-import { RNConfig } from "./config";
-import { deinitalize, initialize } from "./initialize";
-
-const appConfig = RNConfig.getInstance();
-const logger = Logger.getInstance();
-
-export const logoutPerson = async (): Promise => {
- await deinitalize();
- await appConfig.resetConfig();
-};
-
-export const resetPerson = async (): Promise> => {
- logger.debug("Resetting state & getting new state from backend");
- const userId = appConfig.get().personState.data.userId;
- const syncParams = {
- environmentId: appConfig.get().environmentId,
- apiHost: appConfig.get().apiHost,
- ...(userId && { userId }),
- attributes: appConfig.get().attributes,
- };
- await logoutPerson();
- try {
- await initialize(syncParams);
- return okVoid();
- } catch (e) {
- return err(e as NetworkError);
- }
-};
diff --git a/packages/react-native/src/lib/actions.ts b/packages/react-native/src/lib/survey/action.ts
similarity index 52%
rename from packages/react-native/src/lib/actions.ts
rename to packages/react-native/src/lib/survey/action.ts
index 14ca81292f..e175e88e23 100644
--- a/packages/react-native/src/lib/actions.ts
+++ b/packages/react-native/src/lib/survey/action.ts
@@ -1,21 +1,18 @@
-import type { TJsEnvironmentStateSurvey } from "@formbricks/types/js";
-import {
- type InvalidCodeError,
- type NetworkError,
- type Result,
- err,
- okVoid,
-} from "../../../js-core/src/lib/errors";
-import { Logger } from "../../../js-core/src/lib/logger";
-import { shouldDisplayBasedOnPercentage } from "../../../js-core/src/lib/utils";
-import { RNConfig } from "./config";
-import { SurveyStore } from "./survey-store";
+import { RNConfig } from "@/lib/common/config";
+import { Logger } from "@/lib/common/logger";
+import { shouldDisplayBasedOnPercentage } from "@/lib/common/utils";
+import { SurveyStore } from "@/lib/survey/store";
+import type { TEnvironmentStateSurvey } from "@/types/config";
+import { type InvalidCodeError, type NetworkError, type Result, err, okVoid } from "@/types/error";
-const appConfig = RNConfig.getInstance();
-const logger = Logger.getInstance();
-const surveyStore = SurveyStore.getInstance();
+/**
+ * Triggers the display of a survey if it meets the display percentage criteria
+ * @param survey - The survey configuration to potentially display
+ */
+export const triggerSurvey = (survey: TEnvironmentStateSurvey): void => {
+ const surveyStore = SurveyStore.getInstance();
+ const logger = Logger.getInstance();
-export const triggerSurvey = (survey: TJsEnvironmentStateSurvey): void => {
// Check if the survey should be displayed based on displayPercentage
if (survey.displayPercentage) {
const shouldDisplaySurvey = shouldDisplayBasedOnPercentage(survey.displayPercentage);
@@ -28,7 +25,16 @@ export const triggerSurvey = (survey: TJsEnvironmentStateSurvey): void => {
surveyStore.setSurvey(survey);
};
+/**
+ * Tracks an action name and triggers associated surveys
+ * @param name - The name of the action to track
+ * @param alias - Optional alias for the action name
+ * @returns Result indicating success or network error
+ */
export const trackAction = (name: string, alias?: string): Result => {
+ const logger = Logger.getInstance();
+ const appConfig = RNConfig.getInstance();
+
const aliasName = alias ?? name;
logger.debug(`Formbricks: Action "${aliasName}" tracked`);
@@ -51,11 +57,16 @@ export const trackAction = (name: string, alias?: string): Result | Result => {
+/**
+ * Tracks an action by its code and triggers associated surveys (used for code actions only)
+ * @param code - The action code to track
+ * @returns Result indicating success, network error, or invalid code error
+ */
+export const track = (code: string): Result | Result => {
+ const appConfig = RNConfig.getInstance();
+
const {
- environmentState: {
+ environment: {
data: { actionClasses = [] },
},
} = appConfig.get();
diff --git a/packages/react-native/src/lib/survey/state.ts b/packages/react-native/src/lib/survey/state.ts
new file mode 100644
index 0000000000..c7af6974a8
--- /dev/null
+++ b/packages/react-native/src/lib/survey/state.ts
@@ -0,0 +1,98 @@
+import { type TResponseUpdate } from "@/types/response";
+
+export class SurveyState {
+ responseId: string | null = null;
+ displayId: string | null = null;
+ userId: string | null = null;
+ surveyId: string;
+ responseAcc: TResponseUpdate = { finished: false, data: {}, ttc: {}, variables: {} };
+ singleUseId: string | null;
+
+ constructor(
+ surveyId: string,
+ singleUseId?: string | null,
+ responseId?: string | null,
+ userId?: string | null
+ ) {
+ this.surveyId = surveyId;
+ this.userId = userId ?? null;
+ this.singleUseId = singleUseId ?? null;
+ this.responseId = responseId ?? null;
+ }
+
+ /**
+ * Set the current survey ID
+ * @param id - The survey ID
+ */
+ setSurveyId(id: string): void {
+ this.surveyId = id;
+ this.clear(); // Reset the state when setting a new surveyId
+ }
+ /**
+ * Get a copy of the current state
+ */
+ copy(): SurveyState {
+ const copyInstance = new SurveyState(
+ this.surveyId,
+ this.singleUseId ?? undefined,
+ this.responseId ?? undefined,
+ this.userId ?? undefined
+ );
+ copyInstance.responseId = this.responseId;
+ copyInstance.responseAcc = this.responseAcc;
+ return copyInstance;
+ }
+
+ /**
+ * Update the response ID after a successful response creation
+ * @param id - The response ID
+ */
+ updateResponseId(id: string): void {
+ this.responseId = id;
+ }
+
+ /**
+ * Update the display ID after a successful display creation
+ * @param id - The display ID
+ */
+ updateDisplayId(id: string): void {
+ this.displayId = id;
+ }
+
+ /**
+ * Update the user ID
+ * @param id - The user ID
+ */
+ updateUserId(id: string): void {
+ this.userId = id;
+ }
+
+ /**
+ * Accumulate the responses
+ * @param responseUpdate - The new response data to add
+ */
+ accumulateResponse(responseUpdate: TResponseUpdate): void {
+ this.responseAcc = {
+ finished: responseUpdate.finished,
+ ttc: responseUpdate.ttc,
+ data: { ...this.responseAcc.data, ...responseUpdate.data },
+ variables: responseUpdate.variables,
+ displayId: responseUpdate.displayId,
+ };
+ }
+
+ /**
+ * Check if the current accumulated response is finished
+ */
+ isResponseFinished(): boolean {
+ return this.responseAcc.finished;
+ }
+
+ /**
+ * Clear the current state
+ */
+ clear(): void {
+ this.responseId = null;
+ this.responseAcc = { finished: false, data: {}, ttc: {}, variables: {} };
+ }
+}
diff --git a/packages/react-native/src/lib/survey-store.ts b/packages/react-native/src/lib/survey/store.ts
similarity index 68%
rename from packages/react-native/src/lib/survey-store.ts
rename to packages/react-native/src/lib/survey/store.ts
index c67e58be96..92de2fae45 100644
--- a/packages/react-native/src/lib/survey-store.ts
+++ b/packages/react-native/src/lib/survey/store.ts
@@ -1,13 +1,10 @@
-import { type TJsEnvironmentStateSurvey } from "@formbricks/types/js";
+import type { TEnvironmentStateSurvey } from "@/types/config";
-type Listener = (
- state: TJsEnvironmentStateSurvey | null,
- prevSurvey: TJsEnvironmentStateSurvey | null
-) => void;
+type Listener = (state: TEnvironmentStateSurvey | null, prevSurvey: TEnvironmentStateSurvey | null) => void;
export class SurveyStore {
private static instance: SurveyStore | undefined;
- private survey: TJsEnvironmentStateSurvey | null = null;
+ private survey: TEnvironmentStateSurvey | null = null;
private listeners = new Set();
static getInstance(): SurveyStore {
@@ -17,13 +14,13 @@ export class SurveyStore {
return SurveyStore.instance;
}
- public getSurvey(): TJsEnvironmentStateSurvey | null {
+ public getSurvey(): TEnvironmentStateSurvey | null {
return this.survey;
}
- public setSurvey(survey: TJsEnvironmentStateSurvey): void {
+ public setSurvey(survey: TEnvironmentStateSurvey): void {
const prevSurvey = this.survey;
- if (prevSurvey !== survey) {
+ if (prevSurvey?.id !== survey.id) {
this.survey = survey;
this.listeners.forEach((listener) => {
listener(this.survey, prevSurvey);
diff --git a/packages/react-native/src/lib/survey/tests/__mocks__/state.mock.ts b/packages/react-native/src/lib/survey/tests/__mocks__/state.mock.ts
new file mode 100644
index 0000000000..8ce2a56637
--- /dev/null
+++ b/packages/react-native/src/lib/survey/tests/__mocks__/state.mock.ts
@@ -0,0 +1,8 @@
+export const mockSurveyId = "nrlq3epdlgc9zuccy8ngvgca";
+export const mockSingleUseId = "oh40c6hjlbm2lt5gn1bzkhnn";
+export const mockResponseId = "qru1wamotsu8reijra9haej8";
+export const mockUserId = "li7yxnzfgvlnvkp4q7434dpo";
+export const mockDisplayId = "raxvjd8rp35vi0pjt3tg45am";
+export const mockQuestionId = "a3815gq6cld6nrjcmp7keut4";
+export const mockQuestionId2 = "n0u9zu7m8nc3net6x5dpwdl8";
+export const mockSurveyId2 = "rdt38nqnff9xbyrfbtpesmkm";
diff --git a/packages/react-native/src/lib/survey/tests/__mocks__/store.mock.ts b/packages/react-native/src/lib/survey/tests/__mocks__/store.mock.ts
new file mode 100644
index 0000000000..8b0aefcdb0
--- /dev/null
+++ b/packages/react-native/src/lib/survey/tests/__mocks__/store.mock.ts
@@ -0,0 +1,2 @@
+export const mockSurveyId = "jgocyoxk9uifo6u381qahmes";
+export const mockSurveyName = "Test Survey";
diff --git a/packages/react-native/src/lib/survey/tests/action.test.ts b/packages/react-native/src/lib/survey/tests/action.test.ts
new file mode 100644
index 0000000000..76e93a36d8
--- /dev/null
+++ b/packages/react-native/src/lib/survey/tests/action.test.ts
@@ -0,0 +1,176 @@
+import { type Mock, beforeEach, describe, expect, test, vi } from "vitest";
+import { RNConfig } from "@/lib/common/config";
+import { Logger } from "@/lib/common/logger";
+import { shouldDisplayBasedOnPercentage } from "@/lib/common/utils";
+import { track, trackAction, triggerSurvey } from "@/lib/survey/action";
+import { SurveyStore } from "@/lib/survey/store";
+import { type TEnvironmentStateSurvey } from "@/types/config";
+
+vi.mock("@/lib/common/config", () => ({
+ RNConfig: {
+ getInstance: vi.fn(() => ({
+ get: vi.fn(),
+ })),
+ },
+}));
+
+vi.mock("@/lib/survey/store", () => ({
+ SurveyStore: {
+ getInstance: vi.fn(() => ({
+ setSurvey: vi.fn(),
+ })),
+ },
+}));
+
+vi.mock("@/lib/common/logger", () => ({
+ Logger: {
+ getInstance: vi.fn(() => {
+ return {
+ debug: vi.fn(),
+ };
+ }),
+ },
+}));
+
+vi.mock("@/lib/common/utils", () => ({
+ shouldDisplayBasedOnPercentage: vi.fn(),
+}));
+
+describe("survey/action.ts", () => {
+ const mockSurvey = {
+ id: "survey_001",
+ name: "Test Survey",
+ displayPercentage: 50,
+ triggers: [
+ {
+ actionClass: { name: "testAction" },
+ },
+ ],
+ };
+
+ const mockAppConfig = {
+ get: vi.fn(),
+ };
+
+ const mockSurveyStore = {
+ setSurvey: vi.fn(),
+ };
+
+ const mockLogger = {
+ debug: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ const getInstanceRn = vi.spyOn(RNConfig, "getInstance");
+ const getInstanceSurveyStore = vi.spyOn(SurveyStore, "getInstance");
+ const getInstanceLogger = vi.spyOn(Logger, "getInstance");
+
+ // Mock instances
+ getInstanceRn.mockReturnValue(mockAppConfig as unknown as RNConfig);
+ getInstanceSurveyStore.mockReturnValue(mockSurveyStore as unknown as SurveyStore);
+ getInstanceLogger.mockReturnValue(mockLogger as unknown as Logger);
+ });
+
+ describe("triggerSurvey", () => {
+ test("does not trigger survey if displayPercentage criteria is not met", () => {
+ const shouldDisplayBasedOnPercentageMock = vi.mocked(shouldDisplayBasedOnPercentage);
+ shouldDisplayBasedOnPercentageMock.mockReturnValueOnce(false);
+
+ triggerSurvey(mockSurvey as unknown as TEnvironmentStateSurvey);
+
+ // Ensure survey is not set
+ expect(mockSurveyStore.setSurvey).not.toHaveBeenCalled();
+ expect(mockLogger.debug).toHaveBeenCalledWith(
+ 'Survey display of "Test Survey" skipped based on displayPercentage.'
+ );
+ });
+
+ test("triggers survey if displayPercentage criteria is met", () => {
+ // Mock `shouldDisplayBasedOnPercentage` to return true
+ const shouldDisplayBasedOnPercentageMock = vi.mocked(shouldDisplayBasedOnPercentage);
+ shouldDisplayBasedOnPercentageMock.mockReturnValueOnce(true);
+
+ triggerSurvey(mockSurvey as unknown as TEnvironmentStateSurvey);
+
+ // Ensure survey is set
+ expect(mockSurveyStore.setSurvey).toHaveBeenCalledWith(mockSurvey);
+ });
+ });
+
+ describe("trackAction", () => {
+ const mockActiveSurveys = [mockSurvey];
+
+ beforeEach(() => {
+ mockAppConfig.get.mockReturnValue({
+ filteredSurveys: mockActiveSurveys,
+ });
+ });
+
+ test("triggers survey associated with action name", () => {
+ (shouldDisplayBasedOnPercentage as unknown as Mock).mockReturnValue(true);
+
+ trackAction("testAction");
+
+ // Ensure triggerSurvey is called for the matching survey
+ expect(mockSurveyStore.setSurvey).toHaveBeenCalledWith(mockSurvey);
+ });
+
+ test("does not trigger survey if no active surveys are found", () => {
+ mockAppConfig.get.mockReturnValue({
+ filteredSurveys: [],
+ });
+
+ trackAction("testAction");
+
+ // Ensure no surveys are triggered
+ expect(mockSurveyStore.setSurvey).not.toHaveBeenCalled();
+ expect(mockLogger.debug).toHaveBeenCalledWith("No active surveys to display");
+ });
+
+ test("logs tracked action name", () => {
+ trackAction("testAction");
+
+ expect(mockLogger.debug).toHaveBeenCalledWith('Formbricks: Action "testAction" tracked');
+ });
+ });
+
+ describe("track", () => {
+ const mockActionClasses = [
+ {
+ key: "testCode",
+ type: "code",
+ name: "testAction",
+ },
+ ];
+
+ beforeEach(() => {
+ mockAppConfig.get.mockReturnValue({
+ environment: {
+ data: { actionClasses: mockActionClasses },
+ },
+ });
+ });
+
+ test("tracks a valid action by code", () => {
+ const result = track("testCode");
+
+ expect(result.ok).toBe(true);
+ // expect(mockLogger.debug).toHaveBeenCalledWith('Formbricks: Action "testAction" tracked');
+ });
+
+ test("returns error for invalid action code", () => {
+ const result = track("invalidCode");
+
+ expect(result.ok).toBe(false);
+
+ if (!result.ok) {
+ expect(result.error.code).toBe("invalid_code");
+ expect(result.error.message).toBe(
+ "invalidCode action unknown. Please add this action in Formbricks first in order to use it in your code."
+ );
+ }
+ });
+ });
+});
diff --git a/packages/react-native/src/lib/survey/tests/state.test.ts b/packages/react-native/src/lib/survey/tests/state.test.ts
new file mode 100644
index 0000000000..5619ef92fa
--- /dev/null
+++ b/packages/react-native/src/lib/survey/tests/state.test.ts
@@ -0,0 +1,158 @@
+import {
+ mockDisplayId,
+ mockQuestionId,
+ mockQuestionId2,
+ mockResponseId,
+ mockSingleUseId,
+ mockSurveyId,
+ mockSurveyId2,
+ mockUserId,
+} from "./__mocks__/state.mock";
+import { SurveyState } from "@/lib/survey/state";
+import { beforeEach, describe, expect, test } from "vitest";
+
+describe("SurveyState", () => {
+ let surveyState: SurveyState;
+
+ beforeEach(() => {
+ surveyState = new SurveyState(mockSurveyId);
+ });
+
+ describe("constructor", () => {
+ test("initializes with required surveyId", () => {
+ expect(surveyState.surveyId).toBe(mockSurveyId);
+ expect(surveyState.responseId).toBeNull();
+ expect(surveyState.userId).toBeNull();
+ expect(surveyState.singleUseId).toBeNull();
+ });
+
+ test("initializes with all optional parameters", () => {
+ const state = new SurveyState(mockSurveyId, mockSingleUseId, mockResponseId, mockUserId);
+ expect(state.surveyId).toBe(mockSurveyId);
+ expect(state.singleUseId).toBe(mockSingleUseId);
+ expect(state.responseId).toBe(mockResponseId);
+ expect(state.userId).toBe(mockUserId);
+ });
+ });
+
+ describe("setSurveyId", () => {
+ test("updates surveyId and clears state", () => {
+ // First set some data
+ surveyState.responseId = mockResponseId;
+ surveyState.responseAcc = {
+ finished: true,
+ data: { [mockQuestionId]: "test" },
+ ttc: { [mockQuestionId]: 5000 },
+ variables: {},
+ };
+
+ // Then update survey ID
+ surveyState.setSurveyId(mockSurveyId2);
+
+ expect(surveyState.surveyId).toBe(mockSurveyId2);
+ expect(surveyState.responseId).toBeNull();
+ expect(surveyState.responseAcc).toEqual({ finished: false, data: {}, ttc: {}, variables: {} });
+ });
+ });
+
+ describe("copy", () => {
+ test("creates an exact copy of the state", () => {
+ surveyState.responseId = mockResponseId;
+ surveyState.userId = mockUserId;
+ surveyState.singleUseId = mockSingleUseId;
+ surveyState.responseAcc = {
+ finished: true,
+ data: { [mockQuestionId]: "answer1" },
+ ttc: { [mockQuestionId]: 3000 },
+ variables: { var1: "value1" },
+ };
+
+ const copy = surveyState.copy();
+
+ expect(copy).toBeInstanceOf(SurveyState);
+ expect(copy).not.toBe(surveyState); // Different instance
+ expect(copy.surveyId).toBe(surveyState.surveyId);
+ expect(copy.responseId).toBe(surveyState.responseId);
+ expect(copy.userId).toBe(surveyState.userId);
+ expect(copy.singleUseId).toBe(surveyState.singleUseId);
+ expect(copy.responseAcc).toEqual(surveyState.responseAcc);
+ });
+ });
+
+ describe("accumulateResponse", () => {
+ test("accumulates response data correctly", () => {
+ const firstUpdate = {
+ finished: false,
+ data: { [mockQuestionId]: "answer1" },
+ ttc: { [mockQuestionId]: 3000 },
+ variables: { var1: "value1" },
+ displayId: mockDisplayId,
+ };
+
+ const secondUpdate = {
+ finished: true,
+ data: { [mockQuestionId2]: "answer2" },
+ ttc: { [mockQuestionId2]: 2000 },
+ variables: { var2: "value2" },
+ displayId: mockDisplayId,
+ };
+
+ surveyState.accumulateResponse(firstUpdate);
+ expect(surveyState.responseAcc.data).toEqual({ [mockQuestionId]: "answer1" });
+ expect(surveyState.responseAcc.ttc).toEqual({ [mockQuestionId]: 3000 });
+ expect(surveyState.responseAcc.variables).toEqual({ var1: "value1" });
+
+ surveyState.accumulateResponse(secondUpdate);
+ expect(surveyState.responseAcc.data).toEqual({
+ [mockQuestionId]: "answer1",
+ [mockQuestionId2]: "answer2",
+ });
+ expect(surveyState.responseAcc.ttc).toEqual({ [mockQuestionId2]: 2000 });
+ expect(surveyState.responseAcc.variables).toEqual({ var2: "value2" });
+ expect(surveyState.responseAcc.finished).toBe(true);
+ });
+ });
+
+ describe("state management methods", () => {
+ test("updateResponseId sets response ID", () => {
+ surveyState.updateResponseId(mockResponseId);
+ expect(surveyState.responseId).toBe(mockResponseId);
+ });
+
+ test("updateDisplayId sets display ID", () => {
+ surveyState.updateDisplayId(mockDisplayId);
+ expect(surveyState.displayId).toBe(mockDisplayId);
+ });
+
+ test("updateUserId sets user ID", () => {
+ surveyState.updateUserId(mockUserId);
+ expect(surveyState.userId).toBe(mockUserId);
+ });
+
+ test("isResponseFinished returns correct state", () => {
+ expect(surveyState.isResponseFinished()).toBe(false);
+ surveyState.responseAcc.finished = true;
+ expect(surveyState.isResponseFinished()).toBe(true);
+ });
+
+ test("clear resets response state", () => {
+ surveyState.responseId = mockResponseId;
+ surveyState.responseAcc = {
+ finished: true,
+ data: { [mockQuestionId]: "test" },
+ ttc: { [mockQuestionId]: 5000 },
+ variables: { var1: "test" },
+ };
+
+ surveyState.clear();
+
+ expect(surveyState.responseId).toBeNull();
+ expect(surveyState.responseAcc).toEqual({
+ finished: false,
+ data: {},
+ ttc: {},
+ variables: {},
+ });
+ });
+ });
+});
diff --git a/packages/react-native/src/lib/survey/tests/store.test.ts b/packages/react-native/src/lib/survey/tests/store.test.ts
new file mode 100644
index 0000000000..b11f663817
--- /dev/null
+++ b/packages/react-native/src/lib/survey/tests/store.test.ts
@@ -0,0 +1,129 @@
+import { mockSurveyId, mockSurveyName } from "@/lib/survey/tests/__mocks__/store.mock";
+import { SurveyStore } from "@/lib/survey/store";
+import type { TEnvironmentStateSurvey } from "@/types/config";
+import { beforeEach, describe, expect, test, vi } from "vitest";
+
+describe("SurveyStore", () => {
+ let store: SurveyStore;
+
+ beforeEach(() => {
+ // Reset the singleton instance before each test
+ // @ts-expect-error accessing private static property
+ SurveyStore.instance = undefined;
+ store = SurveyStore.getInstance();
+ });
+
+ describe("getInstance", () => {
+ test("returns singleton instance", () => {
+ const instance1 = SurveyStore.getInstance();
+ const instance2 = SurveyStore.getInstance();
+ expect(instance1).toBe(instance2);
+ });
+ });
+
+ describe("getSurvey", () => {
+ test("returns null when no survey is set", () => {
+ expect(store.getSurvey()).toBeNull();
+ });
+
+ test("returns current survey when set", () => {
+ const mockSurvey: TEnvironmentStateSurvey = {
+ id: mockSurveyId,
+ name: mockSurveyName,
+ } as TEnvironmentStateSurvey;
+
+ store.setSurvey(mockSurvey);
+ expect(store.getSurvey()).toBe(mockSurvey);
+ });
+ });
+
+ describe("setSurvey", () => {
+ test("updates survey and notifies listeners when survey changes", () => {
+ const listener = vi.fn();
+ const mockSurvey: TEnvironmentStateSurvey = {
+ id: mockSurveyId,
+ name: mockSurveyName,
+ } as TEnvironmentStateSurvey;
+
+ store.subscribe(listener);
+ store.setSurvey(mockSurvey);
+
+ expect(listener).toHaveBeenCalledWith(mockSurvey, null);
+ expect(store.getSurvey()).toBe(mockSurvey);
+ });
+
+ test("does not notify listeners when setting same survey", () => {
+ const listener = vi.fn();
+ const mockSurvey: TEnvironmentStateSurvey = {
+ id: mockSurveyId,
+ name: mockSurveyName,
+ } as TEnvironmentStateSurvey;
+
+ store.setSurvey(mockSurvey);
+ store.subscribe(listener);
+ store.setSurvey(mockSurvey);
+
+ expect(listener).not.toHaveBeenCalled();
+ });
+ });
+
+ describe("resetSurvey", () => {
+ test("resets survey to null and notifies listeners", () => {
+ const listener = vi.fn();
+ const mockSurvey: TEnvironmentStateSurvey = {
+ id: mockSurveyId,
+ name: mockSurveyName,
+ } as TEnvironmentStateSurvey;
+
+ store.setSurvey(mockSurvey);
+ store.subscribe(listener);
+ store.resetSurvey();
+
+ expect(listener).toHaveBeenCalledWith(null, mockSurvey);
+ expect(store.getSurvey()).toBeNull();
+ });
+
+ test("does not notify listeners when already null", () => {
+ const listener = vi.fn();
+ store.subscribe(listener);
+ store.resetSurvey();
+
+ expect(listener).not.toHaveBeenCalled();
+ expect(store.getSurvey()).toBeNull();
+ });
+ });
+
+ describe("subscribe", () => {
+ test("adds listener and returns unsubscribe function", () => {
+ const listener = vi.fn();
+ const mockSurvey: TEnvironmentStateSurvey = {
+ id: mockSurveyId,
+ name: mockSurveyName,
+ } as TEnvironmentStateSurvey;
+
+ const unsubscribe = store.subscribe(listener);
+ store.setSurvey(mockSurvey);
+ expect(listener).toHaveBeenCalledTimes(1);
+
+ unsubscribe();
+ store.setSurvey({ ...mockSurvey, name: "Updated Survey" } as TEnvironmentStateSurvey);
+ expect(listener).toHaveBeenCalledTimes(1); // Still 1, not called after unsubscribe
+ });
+
+ test("multiple listeners receive updates", () => {
+ const listener1 = vi.fn();
+ const listener2 = vi.fn();
+ const mockSurvey: TEnvironmentStateSurvey = {
+ id: mockSurveyId,
+ name: mockSurveyName,
+ } as TEnvironmentStateSurvey;
+
+ store.subscribe(listener1);
+ store.subscribe(listener2);
+ store.setSurvey(mockSurvey);
+
+ expect(listener1).toHaveBeenCalledWith(mockSurvey, null);
+ expect(listener2).toHaveBeenCalledWith(mockSurvey, null);
+ });
+ });
+});
diff --git a/packages/react-native/src/lib/user/attribute.ts b/packages/react-native/src/lib/user/attribute.ts
new file mode 100644
index 0000000000..cdb3c67e2f
--- /dev/null
+++ b/packages/react-native/src/lib/user/attribute.ts
@@ -0,0 +1,12 @@
+import { UpdateQueue } from "@/lib/user/update-queue";
+import { type NetworkError, type Result, okVoid } from "@/types/error";
+
+export const setAttributes = async (
+ attributes: Record
+ // eslint-disable-next-line @typescript-eslint/require-await -- we want to use promises here
+): Promise> => {
+ const updateQueue = UpdateQueue.getInstance();
+ updateQueue.updateAttributes(attributes);
+ void updateQueue.processUpdates();
+ return okVoid();
+};
diff --git a/packages/react-native/src/lib/user/state.ts b/packages/react-native/src/lib/user/state.ts
new file mode 100644
index 0000000000..99497f0ad2
--- /dev/null
+++ b/packages/react-native/src/lib/user/state.ts
@@ -0,0 +1,54 @@
+import { RNConfig } from "@/lib/common/config";
+import type { TUserState } from "@/types/config";
+
+let userStateSyncIntervalId: number | null = null;
+
+export const DEFAULT_USER_STATE_NO_USER_ID: TUserState = {
+ expiresAt: null,
+ data: {
+ userId: null,
+ segments: [],
+ displays: [],
+ responses: [],
+ lastDisplayAt: null,
+ },
+} as const;
+
+/**
+ * Add a listener to check if the user state has expired with a certain interval
+ */
+export const addUserStateExpiryCheckListener = (): void => {
+ const config = RNConfig.getInstance();
+ const updateInterval = 1000 * 60; // every 60 seconds
+
+ if (userStateSyncIntervalId === null) {
+ const intervalHandler = (): void => {
+ const userId = config.get().user.data.userId;
+
+ if (!userId) {
+ return;
+ }
+
+ // extend the personState validity by 30 minutes:
+ config.update({
+ ...config.get(),
+ user: {
+ ...config.get().user,
+ expiresAt: new Date(new Date().getTime() + 1000 * 60 * 30), // 30 minutes
+ },
+ });
+ };
+
+ userStateSyncIntervalId = setInterval(intervalHandler, updateInterval) as unknown as number;
+ }
+};
+
+/**
+ * Clear the person state expiry check listener
+ */
+export const clearUserStateExpiryCheckListener = (): void => {
+ if (userStateSyncIntervalId) {
+ clearInterval(userStateSyncIntervalId);
+ userStateSyncIntervalId = null;
+ }
+};
diff --git a/packages/react-native/src/lib/user/tests/__mocks__/update-queue.mock.ts b/packages/react-native/src/lib/user/tests/__mocks__/update-queue.mock.ts
new file mode 100644
index 0000000000..0402553306
--- /dev/null
+++ b/packages/react-native/src/lib/user/tests/__mocks__/update-queue.mock.ts
@@ -0,0 +1,6 @@
+export const mockUserId1 = "user_123";
+export const mockUserId2 = "user_456";
+export const mockAttributes = {
+ name: "John Doe",
+ email: "john@example.com",
+};
diff --git a/packages/react-native/src/lib/user/tests/__mocks__/update.mock.ts b/packages/react-native/src/lib/user/tests/__mocks__/update.mock.ts
new file mode 100644
index 0000000000..32c2d6452d
--- /dev/null
+++ b/packages/react-native/src/lib/user/tests/__mocks__/update.mock.ts
@@ -0,0 +1,7 @@
+export const mockUserId = "user_123";
+export const mockEnvironmentId = "ew9ba7urnv7u3eo11k5c1z0r";
+export const mockAppUrl = "https://app.formbricks.com";
+export const mockAttributes = {
+ name: "John Doe",
+ email: "john@example.com",
+};
diff --git a/packages/react-native/src/lib/user/tests/attribute.test.ts b/packages/react-native/src/lib/user/tests/attribute.test.ts
new file mode 100644
index 0000000000..b827c71803
--- /dev/null
+++ b/packages/react-native/src/lib/user/tests/attribute.test.ts
@@ -0,0 +1,76 @@
+import { beforeEach, describe, expect, test, vi } from "vitest";
+import { setAttributes } from "@/lib/user/attribute";
+import { UpdateQueue } from "@/lib/user/update-queue";
+
+export const mockAttributes = {
+ name: "John Doe",
+ email: "john@example.com",
+};
+
+// Mock the UpdateQueue
+vi.mock("@/lib/user/update-queue", () => ({
+ UpdateQueue: {
+ getInstance: vi.fn(() => ({
+ updateAttributes: vi.fn(),
+ processUpdates: vi.fn(),
+ })),
+ },
+}));
+
+describe("User Attributes", () => {
+ const mockUpdateQueue = {
+ updateAttributes: vi.fn(),
+ processUpdates: vi.fn(),
+ };
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+
+ const getInstanceUpdateQueue = vi.spyOn(UpdateQueue, "getInstance");
+ getInstanceUpdateQueue.mockReturnValue(mockUpdateQueue as unknown as UpdateQueue);
+ });
+
+ describe("setAttributes", () => {
+ test("successfully updates attributes and triggers processing", async () => {
+ const result = await setAttributes(mockAttributes);
+
+ // Verify UpdateQueue methods were called correctly
+ expect(mockUpdateQueue.updateAttributes).toHaveBeenCalledWith(mockAttributes);
+ expect(mockUpdateQueue.processUpdates).toHaveBeenCalled();
+
+ // Verify result is ok
+ expect(result.ok).toBe(true);
+ });
+
+ test("processes multiple attribute updates", async () => {
+ const firstAttributes = { name: mockAttributes.name };
+ const secondAttributes = { email: mockAttributes.email };
+
+ await setAttributes(firstAttributes);
+ await setAttributes(secondAttributes);
+
+ expect(mockUpdateQueue.updateAttributes).toHaveBeenCalledTimes(2);
+ expect(mockUpdateQueue.updateAttributes).toHaveBeenNthCalledWith(1, firstAttributes);
+ expect(mockUpdateQueue.updateAttributes).toHaveBeenNthCalledWith(2, secondAttributes);
+ expect(mockUpdateQueue.processUpdates).toHaveBeenCalledTimes(2);
+ });
+
+ test("processes updates asynchronously", async () => {
+ const attributes = { name: mockAttributes.name };
+
+ // Mock processUpdates to be async
+ mockUpdateQueue.processUpdates.mockImplementation(
+ () =>
+ new Promise((resolve) => {
+ setTimeout(resolve, 100);
+ })
+ );
+
+ const result = await setAttributes(attributes);
+
+ expect(result.ok).toBe(true);
+ expect(mockUpdateQueue.processUpdates).toHaveBeenCalled();
+ // The function returns before processUpdates completes due to void operator
+ });
+ });
+});
diff --git a/packages/react-native/src/lib/user/tests/state.test.ts b/packages/react-native/src/lib/user/tests/state.test.ts
new file mode 100644
index 0000000000..decc0d3adf
--- /dev/null
+++ b/packages/react-native/src/lib/user/tests/state.test.ts
@@ -0,0 +1,103 @@
+import { type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest";
+import { RNConfig } from "@/lib/common/config";
+import { addUserStateExpiryCheckListener, clearUserStateExpiryCheckListener } from "@/lib/user/state";
+
+const mockUserId = "user_123";
+
+vi.mock("@/lib/common/config", () => ({
+ RNConfig: {
+ getInstance: vi.fn(() => ({
+ get: vi.fn(),
+ update: vi.fn(),
+ })),
+ },
+}));
+
+describe("User State Expiry Check Listener", () => {
+ let mockRNConfig: MockInstance<() => RNConfig>;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ vi.useFakeTimers(); // Simulate timers
+
+ mockRNConfig = vi.spyOn(RNConfig, "getInstance");
+ });
+
+ afterEach(() => {
+ clearUserStateExpiryCheckListener(); // Ensure cleanup after each test
+ });
+
+ test("should set an interval if not already set and update user state expiry when userId exists", () => {
+ const mockConfig = {
+ get: vi.fn().mockReturnValue({
+ user: { data: { userId: mockUserId } },
+ }),
+ update: vi.fn(),
+ };
+
+ mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig);
+
+ addUserStateExpiryCheckListener();
+
+ // Fast-forward time by 1 minute (60,000 ms)
+ vi.advanceTimersByTime(60_000);
+
+ // Ensure config.update was called with extended expiry time
+ expect(mockConfig.update).toHaveBeenCalledWith({
+ user: {
+ data: { userId: mockUserId },
+ expiresAt: expect.any(Date) as Date,
+ },
+ });
+ });
+
+ test("should not update user state expiry if userId does not exist", () => {
+ const mockConfig = {
+ get: vi.fn().mockReturnValue({
+ user: { data: { userId: null } },
+ }),
+ update: vi.fn(),
+ };
+
+ mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig);
+
+ addUserStateExpiryCheckListener();
+ vi.advanceTimersByTime(60_000); // Fast-forward 1 minute
+
+ expect(mockConfig.update).not.toHaveBeenCalled(); // Ensures no update when no userId
+ });
+
+ test("should not set multiple intervals if already set", () => {
+ const mockConfig = {
+ get: vi.fn().mockReturnValue({
+ user: { data: { userId: mockUserId } },
+ }),
+ update: vi.fn(),
+ };
+
+ mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig);
+
+ addUserStateExpiryCheckListener();
+ addUserStateExpiryCheckListener(); // Call again to check if it prevents multiple intervals
+
+ vi.advanceTimersByTime(60_000); // Fast-forward 1 minute
+
+ expect(mockConfig.update).toHaveBeenCalledTimes(1);
+ });
+
+ test("should clear interval when clearUserStateExpiryCheckListener is called", () => {
+ const mockConfig = {
+ get: vi.fn(),
+ update: vi.fn(),
+ };
+
+ mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig);
+
+ addUserStateExpiryCheckListener();
+ clearUserStateExpiryCheckListener();
+
+ vi.advanceTimersByTime(60_000); // Fast-forward 1 minute
+
+ expect(mockConfig.update).not.toHaveBeenCalled();
+ });
+});
diff --git a/packages/react-native/src/lib/user/tests/update-queue.test.ts b/packages/react-native/src/lib/user/tests/update-queue.test.ts
new file mode 100644
index 0000000000..8dfe6742e2
--- /dev/null
+++ b/packages/react-native/src/lib/user/tests/update-queue.test.ts
@@ -0,0 +1,161 @@
+import { type Mock, beforeEach, describe, expect, test, vi } from "vitest";
+import { mockAttributes, mockUserId1, mockUserId2 } from "@/lib/user/tests/__mocks__/update-queue.mock";
+import { RNConfig } from "@/lib/common/config";
+import { sendUpdates } from "@/lib/user/update";
+import { UpdateQueue } from "@/lib/user/update-queue";
+
+// Mock dependencies
+vi.mock("@/lib/common/config", () => ({
+ RNConfig: {
+ getInstance: vi.fn(() => ({
+ get: vi.fn(() => ({
+ user: {
+ data: {
+ userId: "mock-user-id",
+ },
+ },
+ })),
+ update: vi.fn(),
+ })),
+ },
+}));
+
+vi.mock("@/lib/common/logger", () => ({
+ Logger: {
+ getInstance: vi.fn(() => ({
+ debug: vi.fn(),
+ error: vi.fn(),
+ })),
+ },
+}));
+
+vi.mock("@/lib/user/update", () => ({
+ sendUpdates: vi.fn(),
+}));
+
+describe("UpdateQueue", () => {
+ let updateQueue: UpdateQueue;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // Reset singleton instance
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access -- accessing private static property
+ (UpdateQueue as any).instance = null;
+ updateQueue = UpdateQueue.getInstance();
+ });
+
+ test("getInstance returns singleton instance", () => {
+ const instance1 = UpdateQueue.getInstance();
+ const instance2 = UpdateQueue.getInstance();
+ expect(instance1).toBe(instance2);
+ });
+
+ test("updateUserId sets userId correctly when updates is null", () => {
+ const userId = mockUserId1;
+ updateQueue.updateUserId(userId);
+ expect(updateQueue.getUpdates()).toEqual({
+ userId,
+ attributes: {},
+ });
+ });
+
+ test("updateUserId updates existing userId correctly", () => {
+ const userId1 = mockUserId1;
+ const userId2 = mockUserId2;
+
+ updateQueue.updateUserId(userId1);
+ updateQueue.updateUserId(userId2);
+
+ expect(updateQueue.getUpdates()).toEqual({
+ userId: userId2,
+ attributes: {},
+ });
+ });
+
+ test("updateAttributes sets attributes correctly when updates is null", () => {
+ const attributes = mockAttributes;
+ updateQueue.updateAttributes(attributes);
+
+ expect(updateQueue.getUpdates()).toEqual({
+ userId: "mock-user-id", // from mocked config
+ attributes,
+ });
+ });
+
+ test("updateAttributes merges with existing attributes", () => {
+ updateQueue.updateAttributes({ name: mockAttributes.name });
+ updateQueue.updateAttributes({ email: mockAttributes.email });
+
+ expect(updateQueue.getUpdates()).toEqual({
+ userId: "mock-user-id",
+ attributes: {
+ name: mockAttributes.name,
+ email: mockAttributes.email,
+ },
+ });
+ });
+
+ test("clearUpdates resets updates to null", () => {
+ updateQueue.updateAttributes({ name: mockAttributes.name });
+ updateQueue.clearUpdates();
+ expect(updateQueue.getUpdates()).toBeNull();
+ });
+
+ test("isEmpty returns true when updates is null", () => {
+ expect(updateQueue.isEmpty()).toBe(true);
+ });
+
+ test("isEmpty returns false when updates exist", () => {
+ updateQueue.updateAttributes({ name: mockAttributes.name });
+ expect(updateQueue.isEmpty()).toBe(false);
+ });
+
+ test("processUpdates debounces multiple calls", async () => {
+ // Call processUpdates multiple times in quick succession
+
+ (sendUpdates as Mock).mockReturnValue({
+ ok: true,
+ });
+
+ updateQueue.updateAttributes({ name: mockAttributes.name });
+ updateQueue.updateAttributes({ email: mockAttributes.email });
+
+ // Wait for debounce timeout
+ await new Promise((resolve) => {
+ setTimeout(resolve, 600);
+ });
+
+ await updateQueue.processUpdates();
+
+ // Should only be called once with the merged updates
+ expect(sendUpdates).toHaveBeenCalledTimes(1);
+ });
+
+ test("processUpdates handles language attribute specially when no userId", async () => {
+ const configUpdateMock = vi.fn();
+ (RNConfig.getInstance as Mock).mockImplementation(() => ({
+ get: vi.fn(() => ({
+ user: { data: { userId: "" } },
+ })),
+ update: configUpdateMock,
+ }));
+
+ updateQueue.updateAttributes({ language: "en" });
+ await updateQueue.processUpdates();
+
+ expect(configUpdateMock).toHaveBeenCalled();
+ });
+
+ test("processUpdates throws error when setting attributes without userId", async () => {
+ (RNConfig.getInstance as Mock).mockImplementation(() => ({
+ get: vi.fn(() => ({
+ user: { data: { userId: "" } },
+ })),
+ }));
+
+ updateQueue.updateAttributes({ name: mockAttributes.name });
+ await expect(updateQueue.processUpdates()).rejects.toThrow(
+ "Formbricks can't set attributes without a userId!"
+ );
+ });
+});
diff --git a/packages/react-native/src/lib/user/tests/update.test.ts b/packages/react-native/src/lib/user/tests/update.test.ts
new file mode 100644
index 0000000000..14c42f1f60
--- /dev/null
+++ b/packages/react-native/src/lib/user/tests/update.test.ts
@@ -0,0 +1,221 @@
+import { type Mock, beforeEach, describe, expect, test, vi } from "vitest";
+import { FormbricksAPI } from "@formbricks/api";
+import {
+ mockAppUrl,
+ mockAttributes,
+ mockEnvironmentId,
+ mockUserId,
+} from "@/lib/user/tests/__mocks__/update.mock";
+import { RNConfig } from "@/lib/common/config";
+import { Logger } from "@/lib/common/logger";
+import { sendUpdates, sendUpdatesToBackend } from "@/lib/user/update";
+import { type TUpdates } from "@/types/config";
+
+vi.mock("@/lib/common/config", () => ({
+ RNConfig: {
+ getInstance: vi.fn(() => ({
+ get: vi.fn(),
+ update: vi.fn(),
+ })),
+ },
+}));
+
+vi.mock("@/lib/common/logger", () => ({
+ Logger: {
+ getInstance: vi.fn(() => ({
+ debug: vi.fn(),
+ })),
+ },
+}));
+
+vi.mock("@/lib/common/utils", () => ({
+ filterSurveys: vi.fn(),
+}));
+
+vi.mock("@formbricks/api", () => ({
+ FormbricksAPI: vi.fn().mockImplementation(() => ({
+ client: {
+ user: {
+ createOrUpdate: vi.fn(),
+ },
+ },
+ })),
+}));
+
+describe("sendUpdatesToBackend", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ test("sends user updates to backend and returns updated state", async () => {
+ const mockResponse = {
+ ok: true,
+ data: {
+ state: {
+ data: {
+ userId: mockUserId,
+ attributes: mockAttributes,
+ },
+ },
+ },
+ };
+
+ (FormbricksAPI as Mock).mockImplementation(() => ({
+ client: {
+ user: {
+ createOrUpdate: vi.fn().mockResolvedValue(mockResponse),
+ },
+ },
+ }));
+
+ const result = await sendUpdatesToBackend({
+ appUrl: mockAppUrl,
+ environmentId: mockEnvironmentId,
+ updates: { userId: mockUserId, attributes: mockAttributes },
+ });
+
+ expect(result.ok).toBe(true);
+ if (result.ok) {
+ expect(result.data.state.data).toEqual({ userId: mockUserId, attributes: mockAttributes });
+ }
+ });
+
+ test("returns network error if API call fails", async () => {
+ const mockUpdates: TUpdates = { userId: mockUserId, attributes: mockAttributes };
+
+ (FormbricksAPI as Mock).mockImplementation(() => ({
+ client: {
+ user: {
+ createOrUpdate: vi.fn().mockResolvedValue({
+ ok: false,
+ error: { code: "network_error", message: "Request failed", status: 500 },
+ }),
+ },
+ },
+ }));
+
+ const result = await sendUpdatesToBackend({
+ appUrl: mockAppUrl,
+ environmentId: mockEnvironmentId,
+ updates: mockUpdates,
+ });
+
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.error.code).toBe("network_error");
+ expect(result.error.message).toBe("Error updating user with userId user_123");
+ }
+ });
+
+ test("throws error if network request fails", async () => {
+ const mockUpdates: TUpdates = { userId: mockUserId, attributes: { plan: "premium" } };
+
+ (FormbricksAPI as Mock).mockImplementation(() => ({
+ client: {
+ user: {
+ createOrUpdate: vi.fn().mockRejectedValue(new Error("Network error")),
+ },
+ },
+ }));
+
+ await expect(
+ sendUpdatesToBackend({
+ appUrl: mockAppUrl,
+ environmentId: mockEnvironmentId,
+ updates: mockUpdates,
+ })
+ ).rejects.toThrow("Network error");
+ });
+});
+
+describe("sendUpdates", () => {
+ beforeEach(() => {
+ (RNConfig.getInstance as Mock).mockImplementation(() => ({
+ get: vi.fn().mockReturnValue({
+ appUrl: mockAppUrl,
+ environmentId: mockEnvironmentId,
+ environment: {
+ data: {
+ surveys: [],
+ },
+ },
+ }),
+ update: vi.fn(),
+ }));
+
+ (Logger.getInstance as Mock).mockImplementation(() => ({
+ debug: vi.fn(),
+ }));
+ });
+
+ test("successfully processes updates", async () => {
+ const mockResponse = {
+ ok: true,
+ data: {
+ state: {
+ data: {
+ userId: mockUserId,
+ attributes: mockAttributes,
+ },
+ expiresAt: new Date(Date.now() + 1000 * 60 * 30),
+ },
+ },
+ };
+
+ (FormbricksAPI as Mock).mockImplementation(() => ({
+ client: {
+ user: {
+ createOrUpdate: vi.fn().mockResolvedValue(mockResponse),
+ },
+ },
+ }));
+
+ const result = await sendUpdates({ updates: { userId: mockUserId, attributes: mockAttributes } });
+
+ expect(result.ok).toBe(true);
+ });
+
+ test("handles backend errors", async () => {
+ const mockErrorResponse = {
+ ok: false,
+ error: {
+ code: "invalid_request",
+ status: 400,
+ message: "Invalid request",
+ },
+ };
+
+ (FormbricksAPI as Mock).mockImplementation(() => ({
+ client: {
+ user: {
+ createOrUpdate: vi.fn().mockResolvedValue(mockErrorResponse),
+ },
+ },
+ }));
+
+ const result = await sendUpdates({ updates: { userId: mockUserId, attributes: mockAttributes } });
+
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.error.code).toBe("invalid_request");
+ }
+ });
+
+ test("handles unexpected errors", async () => {
+ (FormbricksAPI as Mock).mockImplementation(() => ({
+ client: {
+ user: {
+ createOrUpdate: vi.fn().mockRejectedValue(new Error("Unexpected error")),
+ },
+ },
+ }));
+
+ const result = await sendUpdates({ updates: { userId: mockUserId, attributes: mockAttributes } });
+
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.error.code).toBe("network_error");
+ expect(result.error.status).toBe(500);
+ }
+ });
+});
diff --git a/packages/react-native/src/lib/user/tests/user.test.ts b/packages/react-native/src/lib/user/tests/user.test.ts
new file mode 100644
index 0000000000..095cfae64b
--- /dev/null
+++ b/packages/react-native/src/lib/user/tests/user.test.ts
@@ -0,0 +1,176 @@
+import { type Mock, type MockInstance, beforeEach, describe, expect, test, vi } from "vitest";
+import { RNConfig } from "@/lib/common/config";
+import { deinitalize, init } from "@/lib/common/initialize";
+import { Logger } from "@/lib/common/logger";
+import { UpdateQueue } from "@/lib/user/update-queue";
+import { logout, logoutUser, setUserId } from "@/lib/user/user";
+
+// Mock dependencies
+vi.mock("@/lib/common/config", () => ({
+ RNConfig: {
+ getInstance: vi.fn(() => ({
+ get: vi.fn(),
+ })),
+ },
+}));
+
+vi.mock("@/lib/common/logger", () => ({
+ Logger: {
+ getInstance: vi.fn(() => ({
+ error: vi.fn(),
+ debug: vi.fn(),
+ })),
+ },
+}));
+
+vi.mock("@/lib/user/update-queue", () => ({
+ UpdateQueue: {
+ getInstance: vi.fn(() => ({
+ updateUserId: vi.fn(),
+ processUpdates: vi.fn(),
+ })),
+ },
+}));
+
+vi.mock("@/lib/common/initialize", () => ({
+ deinitalize: vi.fn(),
+ init: vi.fn(),
+}));
+
+describe("user.ts", () => {
+ const mockUserId = "test-user-123";
+ const mockEnvironmentId = "env-123";
+ const mockAppUrl = "https://test.com";
+
+ let getInstanceConfigMock: MockInstance<() => RNConfig>;
+ let getInstanceLoggerMock: MockInstance<() => Logger>;
+ let getInstanceUpdateQueueMock: MockInstance<() => UpdateQueue>;
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ getInstanceConfigMock = vi.spyOn(RNConfig, "getInstance");
+ getInstanceLoggerMock = vi.spyOn(Logger, "getInstance");
+ getInstanceUpdateQueueMock = vi.spyOn(UpdateQueue, "getInstance");
+ });
+
+ describe("setUserId", () => {
+ test("returns error if userId is already set", async () => {
+ const mockConfig = {
+ get: vi.fn().mockReturnValue({
+ user: {
+ data: {
+ userId: "existing-user",
+ },
+ },
+ }),
+ };
+
+ const mockLogger = {
+ debug: vi.fn(),
+ error: vi.fn(),
+ };
+
+ getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig);
+ getInstanceLoggerMock.mockReturnValue(mockLogger as unknown as Logger);
+
+ const result = await setUserId(mockUserId);
+
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.error.code).toBe("forbidden");
+ expect(result.error.status).toBe(403);
+ }
+ expect(mockLogger.error).toHaveBeenCalled();
+ });
+
+ test("successfully sets userId when none exists", async () => {
+ const mockConfig = {
+ get: vi.fn().mockReturnValue({
+ user: {
+ data: {
+ userId: null,
+ },
+ },
+ }),
+ };
+
+ const mockLogger = {
+ debug: vi.fn(),
+ error: vi.fn(),
+ };
+
+ const mockUpdateQueue = {
+ updateUserId: vi.fn(),
+ processUpdates: vi.fn(),
+ };
+
+ getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig);
+ getInstanceLoggerMock.mockReturnValue(mockLogger as unknown as Logger);
+ getInstanceUpdateQueueMock.mockReturnValue(mockUpdateQueue as unknown as UpdateQueue);
+ const result = await setUserId(mockUserId);
+
+ expect(result.ok).toBe(true);
+ expect(mockUpdateQueue.updateUserId).toHaveBeenCalledWith(mockUserId);
+ expect(mockUpdateQueue.processUpdates).toHaveBeenCalled();
+ });
+ });
+
+ describe("logoutUser", () => {
+ test("calls deinitalize", async () => {
+ await logoutUser();
+ expect(deinitalize).toHaveBeenCalled();
+ });
+ });
+
+ describe("logout", () => {
+ test("successfully reinitializes after logout", async () => {
+ const mockConfig = {
+ get: vi.fn().mockReturnValue({
+ environmentId: mockEnvironmentId,
+ appUrl: mockAppUrl,
+ user: { data: { userId: mockUserId } },
+ }),
+ };
+
+ getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig);
+
+ (init as Mock).mockResolvedValue(undefined);
+
+ const result = await logout();
+
+ expect(deinitalize).toHaveBeenCalled();
+ expect(init).toHaveBeenCalledWith({
+ environmentId: mockEnvironmentId,
+ appUrl: mockAppUrl,
+ });
+ expect(result.ok).toBe(true);
+ });
+
+ test("returns error if initialization fails", async () => {
+ const mockConfig = {
+ get: vi.fn().mockReturnValue({
+ environmentId: mockEnvironmentId,
+ appUrl: mockAppUrl,
+ user: { data: { userId: mockUserId } },
+ }),
+ };
+
+ getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig);
+
+ const mockError = { code: "network_error", message: "Failed to connect" };
+ (init as Mock).mockRejectedValue(mockError);
+
+ const result = await logout();
+
+ expect(deinitalize).toHaveBeenCalled();
+ expect(init).toHaveBeenCalledWith({
+ environmentId: mockEnvironmentId,
+ appUrl: mockAppUrl,
+ });
+ expect(result.ok).toBe(false);
+ if (!result.ok) {
+ expect(result.error).toEqual(mockError);
+ }
+ });
+ });
+});
diff --git a/packages/react-native/src/lib/user/update-queue.ts b/packages/react-native/src/lib/user/update-queue.ts
new file mode 100644
index 0000000000..9edab77ef0
--- /dev/null
+++ b/packages/react-native/src/lib/user/update-queue.ts
@@ -0,0 +1,153 @@
+/* eslint-disable @typescript-eslint/no-empty-function -- required for singleton pattern */
+import { RNConfig } from "@/lib/common/config";
+import { Logger } from "@/lib/common/logger";
+import { sendUpdates } from "@/lib/user/update";
+import type { TAttributes, TUpdates } from "@/types/config";
+
+const logger = Logger.getInstance();
+
+export class UpdateQueue {
+ private static instance: UpdateQueue | null = null;
+ private updates: TUpdates | null = null;
+ private debounceTimeout: NodeJS.Timeout | null = null;
+ private readonly DEBOUNCE_DELAY = 500;
+
+ private constructor() {}
+
+ public static getInstance(): UpdateQueue {
+ if (!UpdateQueue.instance) {
+ UpdateQueue.instance = new UpdateQueue();
+ }
+
+ return UpdateQueue.instance;
+ }
+
+ public updateUserId(userId: string): void {
+ if (!this.updates) {
+ this.updates = {
+ userId,
+ attributes: {},
+ };
+ } else {
+ this.updates = {
+ ...this.updates,
+ userId,
+ };
+ }
+ }
+
+ public updateAttributes(attributes: TAttributes): void {
+ const config = RNConfig.getInstance();
+ // Get userId from updates first, then fallback to config
+ const userId = this.updates?.userId ?? config.get().user.data.userId ?? "";
+
+ if (!this.updates) {
+ this.updates = {
+ userId,
+ attributes,
+ };
+ } else {
+ this.updates = {
+ ...this.updates,
+ userId,
+ attributes: { ...this.updates.attributes, ...attributes },
+ };
+ }
+ }
+
+ public getUpdates(): TUpdates | null {
+ return this.updates;
+ }
+
+ public clearUpdates(): void {
+ this.updates = null;
+ }
+
+ public isEmpty(): boolean {
+ return !this.updates;
+ }
+
+ public async processUpdates(): Promise {
+ if (!this.updates) {
+ return;
+ }
+
+ if (this.debounceTimeout) {
+ clearTimeout(this.debounceTimeout);
+ }
+
+ return new Promise((resolve, reject) => {
+ const handler = async (): Promise => {
+ try {
+ let currentUpdates = { ...this.updates };
+ const config = RNConfig.getInstance();
+
+ if (Object.keys(currentUpdates).length > 0) {
+ // Get userId from either updates or config
+ const effectiveUserId = currentUpdates.userId ?? config.get().user.data.userId;
+ const isLanguageInUpdates = currentUpdates.attributes?.language;
+
+ if (!effectiveUserId && isLanguageInUpdates) {
+ // no user id set but the updates contain a language
+ // we need to set this language in the local config:
+ config.update({
+ ...config.get(),
+ user: {
+ ...config.get().user,
+ data: {
+ ...config.get().user.data,
+ language: currentUpdates.attributes?.language,
+ },
+ },
+ });
+
+ logger.debug("Updated language successfully");
+
+ const { language: _, ...remainingAttributes } = currentUpdates.attributes ?? {};
+
+ // remove language from attributes
+ currentUpdates = {
+ ...currentUpdates,
+ attributes: remainingAttributes,
+ };
+ }
+
+ if (Object.keys(currentUpdates.attributes ?? {}).length > 0 && !effectiveUserId) {
+ const errorMessage =
+ "Formbricks can't set attributes without a userId! Please set a userId first with the setUserId function";
+ logger.error(errorMessage);
+ this.clearUpdates();
+ throw new Error(errorMessage);
+ }
+
+ // Only send updates if we have a userId (either from updates or local storage)
+ if (effectiveUserId) {
+ const result = await sendUpdates({
+ updates: {
+ userId: effectiveUserId,
+ attributes: currentUpdates.attributes ?? {},
+ },
+ });
+
+ if (result.ok) {
+ logger.debug("Updates sent successfully");
+ } else {
+ logger.error("Failed to send updates");
+ }
+ }
+ }
+
+ this.clearUpdates();
+ resolve();
+ } catch (error: unknown) {
+ logger.error(
+ `Failed to process updates: ${error instanceof Error ? error.message : "Unknown error"}`
+ );
+ reject(error as Error);
+ }
+ };
+
+ this.debounceTimeout = setTimeout(() => void handler(), this.DEBOUNCE_DELAY);
+ });
+ }
+}
diff --git a/packages/react-native/src/lib/user/update.ts b/packages/react-native/src/lib/user/update.ts
new file mode 100644
index 0000000000..2237a945e0
--- /dev/null
+++ b/packages/react-native/src/lib/user/update.ts
@@ -0,0 +1,114 @@
+/* eslint-disable no-console -- required for logging errors */
+import { FormbricksAPI } from "@formbricks/api";
+import { RNConfig } from "@/lib/common/config";
+import { Logger } from "@/lib/common/logger";
+import { filterSurveys } from "@/lib/common/utils";
+import { type TUpdates, type TUserState } from "@/types/config";
+import { type ApiErrorResponse, type Result, err, ok, okVoid } from "@/types/error";
+
+export const sendUpdatesToBackend = async ({
+ appUrl,
+ environmentId,
+ updates,
+}: {
+ appUrl: string;
+ environmentId: string;
+ updates: TUpdates;
+}): Promise<
+ Result<
+ {
+ state: TUserState;
+ messages?: string[];
+ },
+ ApiErrorResponse
+ >
+> => {
+ const url = `${appUrl}/api/v1/client/${environmentId}/user`;
+ const api = new FormbricksAPI({ apiHost: appUrl, environmentId });
+
+ try {
+ const response = await api.client.user.createOrUpdate({
+ userId: updates.userId,
+ attributes: updates.attributes,
+ });
+
+ if (!response.ok) {
+ return err({
+ code: response.error.code,
+ status: response.error.status,
+ message: `Error updating user with userId ${updates.userId}`,
+ url: new URL(url),
+ responseMessage: response.error.message,
+ });
+ }
+
+ return ok(response.data);
+ } catch (e: unknown) {
+ const errorTyped = e as { message?: string };
+
+ const error = err({
+ code: "network_error",
+ message: errorTyped.message ?? "Error fetching the person state",
+ status: 500,
+ url: new URL(url),
+ responseMessage: errorTyped.message ?? "Unknown error",
+ });
+
+ // eslint-disable-next-line @typescript-eslint/only-throw-error -- error.error is an Error object
+ throw error.error;
+ }
+};
+
+export const sendUpdates = async ({
+ updates,
+}: {
+ updates: TUpdates;
+}): Promise> => {
+ const config = RNConfig.getInstance();
+ const logger = Logger.getInstance();
+
+ const { appUrl, environmentId } = config.get();
+ // update endpoint call
+ const url = `${appUrl}/api/v1/client/${environmentId}/user`;
+
+ try {
+ const updatesResponse = await sendUpdatesToBackend({ appUrl, environmentId, updates });
+
+ if (updatesResponse.ok) {
+ const userState = updatesResponse.data.state;
+ const filteredSurveys = filterSurveys(config.get().environment, userState);
+
+ // messages => string[] - contains the details of the attributes update
+ // for example, if the attribute "email" was being used for some user or not
+ const messages = updatesResponse.data.messages;
+
+ if (messages && messages.length > 0) {
+ for (const message of messages) {
+ logger.debug(`User update message: ${message}`);
+ }
+ }
+
+ config.update({
+ ...config.get(),
+ user: {
+ ...userState,
+ },
+ filteredSurveys,
+ });
+
+ return okVoid();
+ }
+
+ return err(updatesResponse.error);
+ } catch (e) {
+ console.error("error in sending updates: ", e);
+
+ return err({
+ code: "network_error",
+ message: "Error sending updates",
+ status: 500,
+ url: new URL(url),
+ responseMessage: "Unknown error",
+ });
+ }
+};
diff --git a/packages/react-native/src/lib/user/user.ts b/packages/react-native/src/lib/user/user.ts
new file mode 100644
index 0000000000..d5cbb43038
--- /dev/null
+++ b/packages/react-native/src/lib/user/user.ts
@@ -0,0 +1,63 @@
+import { RNConfig } from "@/lib/common/config";
+import { deinitalize, init } from "@/lib/common/initialize";
+import { Logger } from "@/lib/common/logger";
+import { UpdateQueue } from "@/lib/user/update-queue";
+import { type ApiErrorResponse, type NetworkError, type Result, err, okVoid } from "@/types/error";
+
+// eslint-disable-next-line @typescript-eslint/require-await -- we want to use promises here
+export const setUserId = async (userId: string): Promise> => {
+ const appConfig = RNConfig.getInstance();
+ const logger = Logger.getInstance();
+ const updateQueue = UpdateQueue.getInstance();
+
+ const {
+ data: { userId: currentUserId },
+ } = appConfig.get().user;
+
+ if (currentUserId) {
+ logger.error(
+ "A userId is already set in formbricks, please first call the logout function and then set a new userId"
+ );
+ return err({
+ code: "forbidden",
+ message: "User already set",
+ responseMessage: "User already set",
+ status: 403,
+ });
+ }
+
+ updateQueue.updateUserId(userId);
+ void updateQueue.processUpdates();
+ return okVoid();
+};
+
+export const logoutUser = async (): Promise => {
+ await deinitalize();
+};
+
+export const logout = async (): Promise> => {
+ const logger = Logger.getInstance();
+ const appConfig = RNConfig.getInstance();
+
+ const { userId } = appConfig.get().user.data;
+
+ if (!userId) {
+ logger.debug("No userId is set, please use the setUserId function to set a userId first");
+ return okVoid();
+ }
+
+ logger.debug("Resetting state & getting new state from backend");
+ const initParams = {
+ environmentId: appConfig.get().environmentId,
+ appUrl: appConfig.get().appUrl,
+ };
+
+ void logoutUser();
+
+ try {
+ await init(initParams);
+ return okVoid();
+ } catch (e) {
+ return err(e as NetworkError);
+ }
+};
diff --git a/packages/react-native/src/types/config.ts b/packages/react-native/src/types/config.ts
new file mode 100644
index 0000000000..ddb6826cb2
--- /dev/null
+++ b/packages/react-native/src/types/config.ts
@@ -0,0 +1,154 @@
+/* eslint-disable import/no-extraneous-dependencies -- required for Prisma types */
+import type { ActionClass, Language, Project, Segment, Survey, SurveyLanguage } from "@prisma/client";
+import { z } from "zod";
+import { type TResponseUpdate, ZResponseUpdate } from "@/types/response";
+import { type TFileUploadParams, ZFileUploadParams } from "@/types/storage";
+
+export type TEnvironmentStateSurvey = Pick<
+ Survey,
+ | "id"
+ | "name"
+ | "welcomeCard"
+ | "questions"
+ | "variables"
+ | "type"
+ | "showLanguageSwitch"
+ | "endings"
+ | "autoClose"
+ | "status"
+ | "recontactDays"
+ | "displayLimit"
+ | "displayOption"
+ | "hiddenFields"
+ | "delay"
+ | "projectOverwrites"
+> & {
+ languages: (SurveyLanguage & { language: Language })[];
+ triggers: { actionClass: ActionClass }[];
+ segment?: Segment;
+ displayPercentage: number;
+ type: "link" | "app";
+ styling?: TSurveyStyling;
+};
+
+export type TEnvironmentStateProject = Pick<
+ Project,
+ "id" | "recontactDays" | "clickOutsideClose" | "darkOverlay" | "placement" | "inAppSurveyBranding"
+> & {
+ styling: TProjectStyling;
+};
+
+export type TEnvironmentStateActionClass = Pick;
+
+export interface TEnvironmentState {
+ expiresAt: Date;
+ data: {
+ surveys: TEnvironmentStateSurvey[];
+ actionClasses: TEnvironmentStateActionClass[];
+ project: TEnvironmentStateProject;
+ };
+}
+
+export interface TUserState {
+ expiresAt: Date | null;
+ data: {
+ userId: string | null;
+ segments: string[];
+ displays: { surveyId: string; createdAt: Date }[];
+ responses: string[];
+ lastDisplayAt: Date | null;
+ language?: string;
+ };
+}
+
+export interface TConfig {
+ environmentId: string;
+ appUrl: string;
+ environment: TEnvironmentState;
+ user: TUserState;
+ filteredSurveys: TEnvironmentStateSurvey[];
+ status: {
+ value: "success" | "error";
+ expiresAt: Date | null;
+ };
+}
+
+export type TConfigUpdateInput = Omit & {
+ status?: {
+ value: "success" | "error";
+ expiresAt: Date | null;
+ };
+};
+
+export type TAttributes = Record;
+
+export interface TConfigInput {
+ environmentId: string;
+ appUrl: string;
+}
+
+export interface TStylingColor {
+ light: string;
+ dark?: string | null | undefined;
+}
+
+export interface TBaseStyling {
+ brandColor?: TStylingColor | null;
+ questionColor?: TStylingColor | null;
+ inputColor?: TStylingColor | null;
+ inputBorderColor?: TStylingColor | null;
+ cardBackgroundColor?: TStylingColor | null;
+ cardBorderColor?: TStylingColor | null;
+ cardShadowColor?: TStylingColor | null;
+ highlightBorderColor?: TStylingColor | null;
+ isDarkModeEnabled?: boolean | null;
+ roundness?: number | null;
+ cardArrangement?: {
+ linkSurveys: "casual" | "straight" | "simple";
+ appSurveys: "casual" | "straight" | "simple";
+ } | null;
+ background?: {
+ bg?: string | null;
+ bgType?: "animation" | "color" | "image" | "upload" | null;
+ brightness?: number | null;
+ } | null;
+ hideProgressBar?: boolean | null;
+ isLogoHidden?: boolean | null;
+}
+
+export interface TProjectStyling extends TBaseStyling {
+ allowStyleOverwrite: boolean;
+}
+
+export interface TSurveyStyling extends TBaseStyling {
+ overwriteThemeStyling?: boolean | null;
+}
+
+export interface TWebViewOnMessageData {
+ onFinished?: boolean | null;
+ onDisplay?: boolean | null;
+ onResponse?: boolean | null;
+ responseUpdate?: TResponseUpdate | null;
+ onRetry?: boolean | null;
+ onClose?: boolean | null;
+ onFileUpload?: boolean | null;
+ fileUploadParams?: TFileUploadParams | null;
+ uploadId?: string | null;
+}
+
+export const ZJsRNWebViewOnMessageData = z.object({
+ onFinished: z.boolean().nullish(),
+ onDisplay: z.boolean().nullish(),
+ onResponse: z.boolean().nullish(),
+ responseUpdate: ZResponseUpdate.nullish(),
+ onRetry: z.boolean().nullish(),
+ onClose: z.boolean().nullish(),
+ onFileUpload: z.boolean().nullish(),
+ fileUploadParams: ZFileUploadParams.nullish(),
+ uploadId: z.string().nullish(),
+});
+
+export interface TUpdates {
+ userId: string;
+ attributes?: TAttributes;
+}
diff --git a/packages/react-native/src/types/error.ts b/packages/react-native/src/types/error.ts
new file mode 100644
index 0000000000..744efc4596
--- /dev/null
+++ b/packages/react-native/src/types/error.ts
@@ -0,0 +1,65 @@
+export interface ResultError {
+ ok: false;
+ error: T;
+}
+
+export interface ResultOk {
+ ok: true;
+ value: T;
+}
+
+export type Result = { ok: true; data: T } | { ok: false; error: E };
+
+export const ok = (data: T): Result => ({ ok: true, data });
+
+export const okVoid = (): Result => ({ ok: true, data: undefined });
+
+export const err = (error: E): ResultError => ({
+ ok: false,
+ error,
+});
+
+export interface ApiErrorResponse {
+ code:
+ | "not_found"
+ | "gone"
+ | "bad_request"
+ | "internal_server_error"
+ | "unauthorized"
+ | "method_not_allowed"
+ | "not_authenticated"
+ | "forbidden"
+ | "network_error";
+ message: string;
+ status: number;
+ url?: URL;
+ details?: Record;
+ responseMessage?: string;
+}
+
+export interface MissingFieldError {
+ code: "missing_field";
+ field: string;
+}
+
+export interface MissingPersonError {
+ code: "missing_person";
+ message: string;
+}
+
+export interface NetworkError {
+ code: "network_error";
+ status: number;
+ message: string;
+ url: URL;
+ responseMessage: string;
+}
+export interface NotInitializedError {
+ code: "not_initialized";
+ message: string;
+}
+
+export interface InvalidCodeError {
+ code: "invalid_code";
+ message: string;
+}
diff --git a/packages/react-native/src/types/response.ts b/packages/react-native/src/types/response.ts
new file mode 100644
index 0000000000..c912262e12
--- /dev/null
+++ b/packages/react-native/src/types/response.ts
@@ -0,0 +1,44 @@
+import { z } from "zod";
+
+export type TResponseData = Record>;
+
+export type TResponseTtc = Record;
+
+export type TResponseVariables = Record;
+
+export type TResponseHiddenFieldValue = Record;
+
+export interface TResponseUpdate {
+ finished: boolean;
+ data: TResponseData;
+ language?: string;
+ variables?: TResponseVariables;
+ ttc?: TResponseTtc;
+ meta?: { url?: string; source?: string; action?: string };
+ hiddenFields?: TResponseHiddenFieldValue;
+ displayId?: string | null;
+ endingId?: string | null;
+}
+
+export const ZResponseData = z.record(z.union([z.string(), z.number(), z.array(z.string())]));
+export const ZResponseVariables = z.record(z.union([z.string(), z.number()]));
+export const ZResponseTtc = z.record(z.number());
+export const ZResponseHiddenFieldValue = z.record(z.union([z.string(), z.number(), z.array(z.string())]));
+
+export const ZResponseUpdate = z.object({
+ finished: z.boolean(),
+ data: ZResponseData,
+ language: z.string().optional(),
+ variables: ZResponseVariables.optional(),
+ ttc: ZResponseTtc.optional(),
+ meta: z
+ .object({
+ url: z.string().optional(),
+ source: z.string().optional(),
+ action: z.string().optional(),
+ })
+ .optional(),
+ hiddenFields: ZResponseHiddenFieldValue.optional(),
+ displayId: z.string().nullish(),
+ endingId: z.string().nullish(),
+});
diff --git a/packages/react-native/src/types/storage.ts b/packages/react-native/src/types/storage.ts
new file mode 100644
index 0000000000..a6099a1577
--- /dev/null
+++ b/packages/react-native/src/types/storage.ts
@@ -0,0 +1,35 @@
+import { z } from "zod";
+
+export interface TUploadFileConfig {
+ allowedFileExtensions?: string[] | undefined;
+ surveyId?: string | undefined;
+}
+
+export interface TUploadFileResponse {
+ data: {
+ signedUrl: string;
+ fileUrl: string;
+ signingData: {
+ signature: string;
+ timestamp: number;
+ uuid: string;
+ } | null;
+ updatedFileName: string;
+ presignedFields?: Record | undefined;
+ };
+}
+
+export interface TFileUploadParams {
+ file: { type: string; name: string; base64: string };
+ params: TUploadFileConfig;
+}
+
+export const ZUploadFileConfig = z.object({
+ allowedFileExtensions: z.array(z.string()).optional(),
+ surveyId: z.string().optional(),
+});
+
+export const ZFileUploadParams = z.object({
+ file: z.object({ type: z.string(), name: z.string(), base64: z.string() }),
+ params: ZUploadFileConfig,
+});
diff --git a/packages/react-native/src/types/survey.ts b/packages/react-native/src/types/survey.ts
new file mode 100644
index 0000000000..40e2be1728
--- /dev/null
+++ b/packages/react-native/src/types/survey.ts
@@ -0,0 +1,35 @@
+import type { TEnvironmentStateSurvey, TProjectStyling, TSurveyStyling } from "@/types/config";
+import type { TResponseData, TResponseUpdate } from "@/types/response";
+import type { TFileUploadParams, TUploadFileConfig } from "@/types/storage";
+
+export interface SurveyBaseProps {
+ survey: TEnvironmentStateSurvey;
+ styling: TSurveyStyling | TProjectStyling;
+ isBrandingEnabled: boolean;
+ getSetIsError?: (getSetError: (value: boolean) => void) => void;
+ getSetIsResponseSendingFinished?: (getSetIsResponseSendingFinished: (value: boolean) => void) => void;
+ getSetQuestionId?: (getSetQuestionId: (value: string) => void) => void;
+ getSetResponseData?: (getSetResponseData: (value: TResponseData) => void) => void;
+ onDisplay?: () => void;
+ onResponse?: (response: TResponseUpdate) => void;
+ onFinished?: () => void;
+ onClose?: () => void;
+ onRetry?: () => void;
+ autoFocus?: boolean;
+ isRedirectDisabled?: boolean;
+ prefillResponseData?: TResponseData;
+ skipPrefilled?: boolean;
+ languageCode: string;
+ onFileUpload: (file: TFileUploadParams["file"], config?: TUploadFileConfig) => Promise;
+ responseCount?: number;
+ isCardBorderVisible?: boolean;
+ startAtQuestionId?: string;
+ clickOutside?: boolean;
+ hiddenFieldsRecord?: TResponseData;
+ shouldResetQuestionId?: boolean;
+ fullSizeCards?: boolean;
+}
+
+export interface SurveyInlineProps extends SurveyBaseProps {
+ containerId: string;
+}
diff --git a/packages/react-native/tsconfig.json b/packages/react-native/tsconfig.json
index f3d5c3dc3c..77603fb57e 100644
--- a/packages/react-native/tsconfig.json
+++ b/packages/react-native/tsconfig.json
@@ -1,5 +1,9 @@
{
"compilerOptions": {
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["./src/*"]
+ },
"strict": true
},
"exclude": ["dist", "build", "node_modules"],
diff --git a/packages/react-native/vite.config.ts b/packages/react-native/vite.config.ts
index fa184e9a70..b0fc7f72cf 100644
--- a/packages/react-native/vite.config.ts
+++ b/packages/react-native/vite.config.ts
@@ -1,9 +1,14 @@
import { resolve } from "node:path";
-import { defineConfig } from "vite";
+import { type UserConfig, defineConfig } from "vite";
import dts from "vite-plugin-dts";
-const config = () => {
+const config = (): UserConfig => {
return defineConfig({
+ resolve: {
+ alias: {
+ "@": resolve(__dirname, "src"),
+ },
+ },
optimizeDeps: {
exclude: ["react-native"],
},
@@ -12,7 +17,13 @@ const config = () => {
minify: "terser",
sourcemap: true,
rollupOptions: {
- external: ["react", "react-native", "react-dom", "react-native-webview"],
+ external: [
+ "react",
+ "react-native",
+ "react-dom",
+ "react-native-webview",
+ "@react-native-async-storage/async-storage",
+ ],
},
lib: {
entry: resolve(__dirname, "src/index.ts"),
@@ -22,6 +33,14 @@ const config = () => {
},
},
plugins: [dts({ rollupTypes: true, bundledPackages: ["@formbricks/api", "@formbricks/types"] })],
+ test: {
+ setupFiles: ["./vitest.setup.ts"],
+ coverage: {
+ provider: "v8",
+ reporter: ["text", "json", "html"],
+ include: ["src/lib/**/*.ts"],
+ },
+ },
});
};
diff --git a/packages/react-native/vitest.setup.ts b/packages/react-native/vitest.setup.ts
new file mode 100644
index 0000000000..4761029a38
--- /dev/null
+++ b/packages/react-native/vitest.setup.ts
@@ -0,0 +1,28 @@
+import { afterEach, beforeEach, vi } from "vitest";
+
+beforeEach(() => {
+ vi.resetModules();
+ vi.resetAllMocks();
+});
+
+afterEach(() => {
+ vi.clearAllMocks();
+});
+
+// Mock react-native
+vi.mock("react-native", () => ({
+ Platform: { OS: "ios" },
+}));
+
+// Mock react-native-webview
+vi.mock("react-native-webview", () => ({
+ WebView: vi.fn(),
+}));
+
+vi.mock("@react-native-async-storage/async-storage", () => ({
+ default: {
+ getItem: vi.fn(),
+ setItem: vi.fn(),
+ removeItem: vi.fn(),
+ },
+}));
diff --git a/packages/types/js.ts b/packages/types/js.ts
index 1c8c193961..4490563558 100644
--- a/packages/types/js.ts
+++ b/packages/types/js.ts
@@ -92,17 +92,18 @@ export const ZJsPersonState = z.object({
),
responses: z.array(ZId), // responded survey ids
lastDisplayAt: z.date().nullable(),
+ language: z.string().optional(),
}),
});
export type TJsPersonState = z.infer;
-export const ZJsPersonIdentifyInput = z.object({
+export const ZJsUserIdentifyInput = z.object({
environmentId: z.string().cuid(),
userId: z.string(),
});
-export type TJsPersonIdentifyInput = z.infer;
+export type TJsPersonIdentifyInput = z.infer;
export const ZJsConfig = z.object({
environmentId: z.string().cuid(),
@@ -149,6 +150,11 @@ export const ZJsContactsUpdateAttributeInput = z.object({
attributes: ZAttributes,
});
+export const ZJsUserUpdateInput = z.object({
+ userId: z.string().trim().min(1),
+ attributes: ZAttributes.optional(),
+});
+
export type TJsPeopleUpdateAttributeInput = z.infer;
export type TJsPeopleUserIdInput = z.infer;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 23ec500b01..13b7fc14a5 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -73,12 +73,15 @@ importers:
'@formbricks/react-native':
specifier: workspace:*
version: link:../../packages/react-native
+ '@react-native-async-storage/async-storage':
+ specifier: 2.1.0
+ version: 2.1.0(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))
expo:
- specifier: 52.0.18
- version: 52.0.18(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
+ specifier: 52.0.28
+ version: 52.0.28(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
expo-status-bar:
- specifier: 2.0.0
- version: 2.0.0(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
+ specifier: 2.0.1
+ version: 2.0.1(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
react:
specifier: 18.3.1
version: 18.3.1
@@ -86,18 +89,18 @@ importers:
specifier: 18.3.1
version: 18.3.1(react@18.3.1)
react-native:
- specifier: 0.76.5
- version: 0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1)
+ specifier: 0.76.6
+ version: 0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1)
react-native-webview:
specifier: 13.12.5
- version: 13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
+ version: 13.12.5(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
devDependencies:
'@babel/core':
specifier: 7.26.0
version: 7.26.0
'@types/react':
- specifier: 19.0.1
- version: 19.0.1
+ specifier: 18.3.18
+ version: 18.3.18
typescript:
specifier: 5.7.2
version: 5.7.2
@@ -995,9 +998,15 @@ importers:
packages/react-native:
dependencies:
+ '@react-native-async-storage/async-storage':
+ specifier: '>=2.1.0'
+ version: 2.1.0(react-native@0.74.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@types/react@18.3.11)(encoding@0.1.13)(react@18.3.1))
react-native-webview:
specifier: '>=13.0.0'
version: 13.12.5(react-native@0.74.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@types/react@18.3.11)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
+ zod:
+ specifier: 3.24.1
+ version: 3.24.1
devDependencies:
'@formbricks/api':
specifier: workspace:*
@@ -1005,18 +1014,12 @@ importers:
'@formbricks/config-typescript':
specifier: workspace:*
version: link:../config-typescript
- '@formbricks/lib':
- specifier: workspace:*
- version: link:../lib
- '@formbricks/types':
- specifier: workspace:*
- version: link:../types
- '@react-native-async-storage/async-storage':
- specifier: 2.1.0
- version: 2.1.0(react-native@0.74.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@types/react@18.3.11)(encoding@0.1.13)(react@18.3.1))
'@types/react':
specifier: 18.3.11
version: 18.3.11
+ '@vitest/coverage-v8':
+ specifier: 3.0.4
+ version: 3.0.4(vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.4.1)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0))
react:
specifier: 18.3.1
version: 18.3.1
@@ -1032,6 +1035,9 @@ importers:
vite-plugin-dts:
specifier: 4.3.0
version: 4.3.0(@types/node@22.10.2)(rollup@4.32.1)(typescript@5.7.2)(vite@6.0.9(@types/node@22.10.2)(jiti@2.4.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0))
+ vitest:
+ specifier: 3.0.4
+ version: 3.0.4(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.4.1)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0)
packages/surveys:
dependencies:
@@ -2285,6 +2291,10 @@ packages:
'@bcoe/v8-coverage@0.2.3':
resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==}
+ '@bcoe/v8-coverage@1.0.2':
+ resolution: {integrity: sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==}
+ engines: {node: '>=18'}
+
'@calcom/embed-core@1.5.1':
resolution: {integrity: sha512-wykzh1GKj5xhGxDJeCRJ7OulAgn9GVMYD/mmOBbvn06c3m9Lqoqn09E5kJ+DY+aokUncQPcstNsdiHsURjMuVw==}
@@ -3067,8 +3077,8 @@ packages:
resolution: {integrity: sha512-+Lla7nYSiHZirgK+U/uYzsLv/X+HaJienbD5AKX1UQZHYfWaP+9uuQluRB4GrEVWF0GZ7vEVp/jzaOT9k/SQlg==}
engines: {node: '>=0.10.0'}
- '@expo/cli@0.22.5':
- resolution: {integrity: sha512-A2wYKtcBbEEyRUAyUeMDd356UROo1xaMl7ZaZC5tQOdIhvWKelRd4f3QCaI56D9B4EMWLg9pVuPVbAMz8zJ4+A==}
+ '@expo/cli@0.22.11':
+ resolution: {integrity: sha512-D5Vl7IBLi53WmL57NAFYB1mIqlMQxDIZVzbi/FTpo5a3oIHELKr0ElTKeOLf1f1/Y3FA7cxgphoawdA0+O1JWQ==}
hasBin: true
'@expo/code-signing-certificates@0.0.5':
@@ -3089,8 +3099,8 @@ packages:
'@expo/env@0.4.1':
resolution: {integrity: sha512-oDtbO3i9yXD1nx93acWiPTWGljJ3vABn35x1NAbqtQ2JL6mFOcRcArt1dwi4imZyLnG4VCcjabT9irj+LgYntw==}
- '@expo/fingerprint@0.11.3':
- resolution: {integrity: sha512-9lgXmcIePvZ7Wef63XtvuN3HfCUevF4E4tQPdEbH9/dUWwpOvvwQ3KT4OJ9jdh8JJ3nTdO9eDQ/8k8xr1aQ5Kg==}
+ '@expo/fingerprint@0.11.7':
+ resolution: {integrity: sha512-2rfYVS4nqWmOPQk+AL5GPfPSawbqqmI5mL++bxAhWADt+d+fjoQYfIrGtjZxQ30f9o/a1PrRPVSuh2j09+diVg==}
hasBin: true
'@expo/image-utils@0.6.4':
@@ -3099,9 +3109,6 @@ packages:
'@expo/json-file@9.0.1':
resolution: {integrity: sha512-ZVPhbbEBEwafPCJ0+kI25O2Iivt3XKHEKAADCml1q2cmOIbQnKgLyn8DpOJXqWEyRQr/VWS+hflBh8DU2YFSqg==}
- '@expo/metro-config@0.19.7':
- resolution: {integrity: sha512-6Ti05d6AyvXstMpaRGh2EsdGSJzmOh9ju3gMmcjxckn/cimNL39qRQSrnqYc0R/DEZiRFL7N9mVE/0uG668ojw==}
-
'@expo/metro-config@0.19.9':
resolution: {integrity: sha512-JAsLWhFQqwLH0KsI4OMbPXsKFji5KJEmsi+/02Sz1GCT17YrjRmv1fZ91regUS/FUH2Y/PDAE/+2ulrTgMeG7A==}
@@ -4655,18 +4662,14 @@ packages:
resolution: {integrity: sha512-1XmRhqQchN+pXPKEKYdpJlwESxVomJOxtEnIkbo7GAlaN2sym84fHEGDXAjLilih5GVPpcpSmFzTy8jx3LtaFg==}
engines: {node: '>=18'}
- '@react-native/assets-registry@0.76.5':
- resolution: {integrity: sha512-MN5dasWo37MirVcKWuysRkRr4BjNc81SXwUtJYstwbn8oEkfnwR9DaqdDTo/hHOnTdhafffLIa2xOOHcjDIGEw==}
+ '@react-native/assets-registry@0.76.6':
+ resolution: {integrity: sha512-YI8HoReYiIwdFQs+k9Q9qpFTnsyYikZxgs/UVtVbhKixXDQF6F9LLvj2naOx4cfV+RGybNKxwmDl1vUok/dRFQ==}
engines: {node: '>=18'}
'@react-native/babel-plugin-codegen@0.74.87':
resolution: {integrity: sha512-+vJYpMnENFrwtgvDfUj+CtVJRJuUnzAUYT0/Pb68Sq9RfcZ5xdcCuUgyf7JO+akW2VTBoJY427wkcxU30qrWWw==}
engines: {node: '>=18'}
- '@react-native/babel-plugin-codegen@0.76.5':
- resolution: {integrity: sha512-xe7HSQGop4bnOLMaXt0aU+rIatMNEQbz242SDl8V9vx5oOTI0VbZV9yLy6yBc6poUlYbcboF20YVjoRsxX4yww==}
- engines: {node: '>=18'}
-
'@react-native/babel-plugin-codegen@0.76.6':
resolution: {integrity: sha512-yFC9I/aDBOBz3ZMlqKn2NY/mDUtCksUNZ7AQmBiTAeVTUP0ujEjE0hTOx5Qd+kok7A7hwZEX87HdSgjiJZfr5g==}
engines: {node: '>=18'}
@@ -4677,12 +4680,6 @@ packages:
peerDependencies:
'@babel/core': '*'
- '@react-native/babel-preset@0.76.5':
- resolution: {integrity: sha512-1Nu5Um4EogOdppBLI4pfupkteTjWfmI0hqW8ezWTg7Bezw0FtBj8yS8UYVd3wTnDFT9A5mA2VNoNUqomJnvj2A==}
- engines: {node: '>=18'}
- peerDependencies:
- '@babel/core': '*'
-
'@react-native/babel-preset@0.76.6':
resolution: {integrity: sha512-ojlVWY6S/VE/nb9hIRetPMTsW9ZmGb2R3dnToEXAtQQDz41eHMHXbkw/k2h0THp6qhas25ruNvn3N5n2o+lBzg==}
engines: {node: '>=18'}
@@ -4695,12 +4692,6 @@ packages:
peerDependencies:
'@babel/preset-env': ^7.1.6
- '@react-native/codegen@0.76.5':
- resolution: {integrity: sha512-FoZ9VRQ5MpgtDAnVo1rT9nNRfjnWpE40o1GeJSDlpUMttd36bVXvsDm8W/NhX8BKTWXSX+CPQJsRcvN1UPYGKg==}
- engines: {node: '>=18'}
- peerDependencies:
- '@babel/preset-env': ^7.1.6
-
'@react-native/codegen@0.76.6':
resolution: {integrity: sha512-BABb3e5G/+hyQYEYi0AODWh2km2d8ERoASZr6Hv90pVXdUHRYR+yxCatX7vSd9rnDUYndqRTzD0hZWAucPNAKg==}
engines: {node: '>=18'}
@@ -4711,8 +4702,8 @@ packages:
resolution: {integrity: sha512-EgJG9lSr8x3X67dHQKQvU6EkO+3ksVlJHYIVv6U/AmW9dN80BEFxgYbSJ7icXS4wri7m4kHdgeq2PQ7/3vvrTQ==}
engines: {node: '>=18'}
- '@react-native/community-cli-plugin@0.76.5':
- resolution: {integrity: sha512-3MKMnlU0cZOWlMhz5UG6WqACJiWUrE3XwBEumzbMmZw3Iw3h+fIsn+7kLLE5EhzqLt0hg5Y4cgYFi4kOaNgq+g==}
+ '@react-native/community-cli-plugin@0.76.6':
+ resolution: {integrity: sha512-nETlc/+U5cESVluzzgN0OcVfcoMijGBaDWzOaJhoYUodcuqnqtu75XsSEc7yzlYjwNQG+vF83mu9CQGezruNMA==}
engines: {node: '>=18'}
peerDependencies:
'@react-native-community/cli-server-api': '*'
@@ -4724,32 +4715,32 @@ packages:
resolution: {integrity: sha512-MN95DJLYTv4EqJc+9JajA3AJZSBYJz2QEJ3uWlHrOky2vKrbbRVaW1ityTmaZa2OXIvNc6CZwSRSE7xCoHbXhQ==}
engines: {node: '>=18'}
- '@react-native/debugger-frontend@0.76.5':
- resolution: {integrity: sha512-5gtsLfBaSoa9WP8ToDb/8NnDBLZjv4sybQQj7rDKytKOdsXm3Pr2y4D7x7GQQtP1ZQRqzU0X0OZrhRz9xNnOqA==}
+ '@react-native/debugger-frontend@0.76.6':
+ resolution: {integrity: sha512-kP97xMQjiANi5/lmf8MakS7d8FTJl+BqYHQMqyvNiY+eeWyKnhqW2GL2v3eEUBAuyPBgJGivuuO4RvjZujduJg==}
engines: {node: '>=18'}
'@react-native/dev-middleware@0.74.87':
resolution: {integrity: sha512-7TmZ3hTHwooYgIHqc/z87BMe1ryrIqAUi+AF7vsD+EHCGxHFdMjSpf1BZ2SUPXuLnF2cTiTfV2RwhbPzx0tYIA==}
engines: {node: '>=18'}
- '@react-native/dev-middleware@0.76.5':
- resolution: {integrity: sha512-f8eimsxpkvMgJia7POKoUu9uqjGF6KgkxX4zqr/a6eoR1qdEAWUd6PonSAqtag3PAqvEaJpB99gLH2ZJI1nDGg==}
+ '@react-native/dev-middleware@0.76.6':
+ resolution: {integrity: sha512-1bAyd2/X48Nzb45s5l2omM75vy764odx/UnDs4sJfFCuK+cupU4nRPgl0XWIqgdM/2+fbQ3E4QsVS/WIKTFxvQ==}
engines: {node: '>=18'}
'@react-native/gradle-plugin@0.74.87':
resolution: {integrity: sha512-T+VX0N1qP+U9V4oAtn7FTX7pfsoVkd1ocyw9swYXgJqU2fK7hC9famW7b3s3ZiufPGPr1VPJe2TVGtSopBjL6A==}
engines: {node: '>=18'}
- '@react-native/gradle-plugin@0.76.5':
- resolution: {integrity: sha512-7KSyD0g0KhbngITduC8OABn0MAlJfwjIdze7nA4Oe1q3R7qmAv+wQzW+UEXvPah8m1WqFjYTkQwz/4mK3XrQGw==}
+ '@react-native/gradle-plugin@0.76.6':
+ resolution: {integrity: sha512-sDzpf4eiynryoS6bpYCweGoxSmWgCSx9lzBoxIIW+S6siyGiTaffzZHWCm8mIn9UZsSPlEO37q62ggnR9Zu/OA==}
engines: {node: '>=18'}
'@react-native/js-polyfills@0.74.87':
resolution: {integrity: sha512-M5Evdn76CuVEF0GsaXiGi95CBZ4IWubHqwXxV9vG9CC9kq0PSkoM2Pn7Lx7dgyp4vT7ccJ8a3IwHbe+5KJRnpw==}
engines: {node: '>=18'}
- '@react-native/js-polyfills@0.76.5':
- resolution: {integrity: sha512-ggM8tcKTcaqyKQcXMIvcB0vVfqr9ZRhWVxWIdiFO1mPvJyS6n+a+lLGkgQAyO8pfH0R1qw6K9D0nqbbDo865WQ==}
+ '@react-native/js-polyfills@0.76.6':
+ resolution: {integrity: sha512-cDD7FynxWYxHkErZzAJtzPGhJ13JdOgL+R0riTh0hCovOfIUz9ItffdLQv2nx48lnvMTQ+HZXMnGOZnsFCNzQw==}
engines: {node: '>=18'}
'@react-native/metro-babel-transformer@0.74.87':
@@ -4758,8 +4749,8 @@ packages:
peerDependencies:
'@babel/core': '*'
- '@react-native/metro-babel-transformer@0.76.5':
- resolution: {integrity: sha512-Cm9G5Sg5BDty3/MKa3vbCAJtT3YHhlEaPlQALLykju7qBS+pHZV9bE9hocfyyvc5N/osTIGWxG5YOfqTeMu1oQ==}
+ '@react-native/metro-babel-transformer@0.76.6':
+ resolution: {integrity: sha512-xSBi9jPliThu5HRSJvluqUlDOLLEmf34zY/U7RDDjEbZqC0ufPcPS7c5XsSg0GDPiXc7lgjBVesPZsKFkoIBgA==}
engines: {node: '>=18'}
peerDependencies:
'@babel/core': '*'
@@ -4767,9 +4758,6 @@ packages:
'@react-native/normalize-colors@0.74.87':
resolution: {integrity: sha512-Xh7Nyk/MPefkb0Itl5Z+3oOobeG9lfLb7ZOY2DKpFnoCE1TzBmib9vMNdFaLdSxLIP+Ec6icgKtdzYg8QUPYzA==}
- '@react-native/normalize-colors@0.76.5':
- resolution: {integrity: sha512-6QRLEok1r55gLqj+94mEWUENuU5A6wsr2OoXpyq/CgQ7THWowbHtru/kRGRr6o3AQXrVnZheR60JNgFcpNYIug==}
-
'@react-native/normalize-colors@0.76.6':
resolution: {integrity: sha512-1n4udXH2Cla31iA/8eLRdhFHpYUYK1NKWCn4m1Sr9L4SarWKAYuRFliK1fcLvPPALCFoFlWvn8I0ekdUOHMzDQ==}
@@ -4784,8 +4772,8 @@ packages:
'@types/react':
optional: true
- '@react-native/virtualized-lists@0.76.5':
- resolution: {integrity: sha512-M/fW1fTwxrHbcx0OiVOIxzG6rKC0j9cR9Csf80o77y1Xry0yrNPpAlf8D1ev3LvHsiAUiRNFlauoPtodrs2J1A==}
+ '@react-native/virtualized-lists@0.76.6':
+ resolution: {integrity: sha512-0HUWVwJbRq1BWFOu11eOWGTSmK9nMHhoMPyoI27wyWcl/nqUx7HOxMbRVq0DsTCyATSMPeF+vZ6o1REapcNWKw==}
engines: {node: '>=18'}
peerDependencies:
'@types/react': ^18.2.6
@@ -6137,12 +6125,24 @@ packages:
'@vitest/browser':
optional: true
+ '@vitest/coverage-v8@3.0.4':
+ resolution: {integrity: sha512-f0twgRCHgbs24Dp8cLWagzcObXMcuKtAwgxjJV/nnysPAJJk1JiKu/W0gIehZLmkljhJXU/E0/dmuQzsA/4jhA==}
+ peerDependencies:
+ '@vitest/browser': 3.0.4
+ vitest: 3.0.4
+ peerDependenciesMeta:
+ '@vitest/browser':
+ optional: true
+
'@vitest/expect@2.0.5':
resolution: {integrity: sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==}
'@vitest/expect@2.1.8':
resolution: {integrity: sha512-8ytZ/fFHq2g4PJVAtDX57mayemKgDR6X3Oa2Foro+EygiOJHUXhCqBAAKQYYajZpFoIfvBCF1j6R6IYRSIUFuw==}
+ '@vitest/expect@3.0.4':
+ resolution: {integrity: sha512-Nm5kJmYw6P2BxhJPkO3eKKhGYKRsnqJqf+r0yOGRKpEP+bSCBDsjXgiu1/5QFrnPMEgzfC38ZEjvCFgaNBC0Eg==}
+
'@vitest/expect@3.0.5':
resolution: {integrity: sha512-nNIOqupgZ4v5jWuQx2DSlHLEs7Q4Oh/7AYwNyE+k0UQzG7tSmjPXShUikn1mpNGzYEN2jJbTvLejwShMitovBA==}
@@ -6157,6 +6157,17 @@ packages:
vite:
optional: true
+ '@vitest/mocker@3.0.4':
+ resolution: {integrity: sha512-gEef35vKafJlfQbnyOXZ0Gcr9IBUsMTyTLXsEQwuyYAerpHqvXhzdBnDFuHLpFqth3F7b6BaFr4qV/Cs1ULx5A==}
+ peerDependencies:
+ msw: ^2.4.9
+ vite: ^5.0.0 || ^6.0.0
+ peerDependenciesMeta:
+ msw:
+ optional: true
+ vite:
+ optional: true
+
'@vitest/mocker@3.0.5':
resolution: {integrity: sha512-CLPNBFBIE7x6aEGbIjaQAX03ZZlBMaWwAjBdMkIf/cAn6xzLTiM3zYqO/WAbieEjsAZir6tO71mzeHZoodThvw==}
peerDependencies:
@@ -6174,18 +6185,27 @@ packages:
'@vitest/pretty-format@2.1.8':
resolution: {integrity: sha512-9HiSZ9zpqNLKlbIDRWOnAWqgcA7xu+8YxXSekhr0Ykab7PAYFkhkwoqVArPOtJhPmYeE2YHgKZlj3CP36z2AJQ==}
+ '@vitest/pretty-format@3.0.4':
+ resolution: {integrity: sha512-ts0fba+dEhK2aC9PFuZ9LTpULHpY/nd6jhAQ5IMU7Gaj7crPCTdCFfgvXxruRBLFS+MLraicCuFXxISEq8C93g==}
+
'@vitest/pretty-format@3.0.5':
resolution: {integrity: sha512-CjUtdmpOcm4RVtB+up8r2vVDLR16Mgm/bYdkGFe3Yj/scRfCpbSi2W/BDSDcFK7ohw8UXvjMbOp9H4fByd/cOA==}
'@vitest/runner@2.1.8':
resolution: {integrity: sha512-17ub8vQstRnRlIU5k50bG+QOMLHRhYPAna5tw8tYbj+jzjcspnwnwtPtiOlkuKC4+ixDPTuLZiqiWWQ2PSXHVg==}
+ '@vitest/runner@3.0.4':
+ resolution: {integrity: sha512-dKHzTQ7n9sExAcWH/0sh1elVgwc7OJ2lMOBrAm73J7AH6Pf9T12Zh3lNE1TETZaqrWFXtLlx3NVrLRb5hCK+iw==}
+
'@vitest/runner@3.0.5':
resolution: {integrity: sha512-BAiZFityFexZQi2yN4OX3OkJC6scwRo8EhRB0Z5HIGGgd2q+Nq29LgHU/+ovCtd0fOfXj5ZI6pwdlUmC5bpi8A==}
'@vitest/snapshot@2.1.8':
resolution: {integrity: sha512-20T7xRFbmnkfcmgVEz+z3AU/3b0cEzZOt/zmnvZEctg64/QZbSDJEVm9fLnnlSi74KibmRsO9/Qabi+t0vCRPg==}
+ '@vitest/snapshot@3.0.4':
+ resolution: {integrity: sha512-+p5knMLwIk7lTQkM3NonZ9zBewzVp9EVkVpvNta0/PlFWpiqLaRcF4+33L1it3uRUCh0BGLOaXPPGEjNKfWb4w==}
+
'@vitest/snapshot@3.0.5':
resolution: {integrity: sha512-GJPZYcd7v8QNUJ7vRvLDmRwl+a1fGg4T/54lZXe+UOGy47F9yUfE18hRCtXL5aHN/AONu29NGzIXSVFh9K0feA==}
@@ -6195,6 +6215,9 @@ packages:
'@vitest/spy@2.1.8':
resolution: {integrity: sha512-5swjf2q95gXeYPevtW0BLk6H8+bPlMb4Vw/9Em4hFxDcaOxS+e0LOX4yqNxoHzMR2akEB2xfpnWUzkZokmgWDg==}
+ '@vitest/spy@3.0.4':
+ resolution: {integrity: sha512-sXIMF0oauYyUy2hN49VFTYodzEAu744MmGcPR3ZBsPM20G+1/cSW/n1U+3Yu/zHxX2bIDe1oJASOkml+osTU6Q==}
+
'@vitest/spy@3.0.5':
resolution: {integrity: sha512-5fOzHj0WbUNqPK6blI/8VzZdkBlQLnT25knX0r4dbZI9qoZDf3qAdjoMmDcLG5A83W6oUUFJgUd0EYBc2P5xqg==}
@@ -6204,6 +6227,9 @@ packages:
'@vitest/utils@2.1.8':
resolution: {integrity: sha512-dwSoui6djdwbfFmIgbIjX2ZhIoG7Ex/+xpxyiEgIGzjliY8xGkcpITKTlp6B4MgtGkF2ilvm97cPM96XZaAgcA==}
+ '@vitest/utils@3.0.4':
+ resolution: {integrity: sha512-8BqC1ksYsHtbWH+DfpOAKrFw3jl3Uf9J7yeFh85Pz52IWuh1hBBtyfEbRNNZNjl8H8A5yMLH9/t+k7HIKzQcZQ==}
+
'@vitest/utils@3.0.5':
resolution: {integrity: sha512-N9AX0NUoUtVwKwy21JtwzaqR5L5R5A99GAbrHfCCXK1lp593i/3AZAXhSP43wRQuxYsflrdzEfXZFo1reR1Nkg==}
@@ -8196,21 +8222,21 @@ packages:
expo: '*'
react: '*'
- expo-modules-autolinking@2.0.4:
- resolution: {integrity: sha512-e0p+19NhmD50U7s7BV7kWIypWmTNC9n/VlJKlXS05hM/zX7pe6JKmXyb+BFnXJq3SLBalLCUY0tu2gEUF3XeVg==}
+ expo-modules-autolinking@2.0.7:
+ resolution: {integrity: sha512-rkGc6a/90AC3q8wSy4V+iIpq6Fd0KXmQICKrvfmSWwrMgJmLfwP4QTrvLYPYOOMjFwNJcTaohcH8vzW/wYKrMg==}
hasBin: true
- expo-modules-core@2.1.1:
- resolution: {integrity: sha512-yQzYCLR2mre4BNMXuqkeJ0oSNgmGEMI6BcmIzeNZbC2NFEjiaDpKvlV9bclYCtyVhUEVNbJcEPYMr6c1Y4eR4w==}
+ expo-modules-core@2.2.0:
+ resolution: {integrity: sha512-mOFEHIe6jZ7G5pYUVSQ2Ghs3CUr9Uz6DOh4JI+4PsTf0gmEvMmMEOrxirS89jRWQjXPJ7QaGBK0CJrZlj/Sdeg==}
- expo-status-bar@2.0.0:
- resolution: {integrity: sha512-vxxdpvpNDMTEc5uTiIrbTvySKKUsOACmfl8OZuUdjNle05oGqwtq3v5YObwym/njSByjoyuZX8UpXBZnxvarwQ==}
+ expo-status-bar@2.0.1:
+ resolution: {integrity: sha512-AkIPX7jWHRPp83UBZ1iXtVvyr0g+DgBVvIXTtlmPtmUsm8Vq9Bb5IGj86PW8osuFlgoTVAg7HI/+Ok7yEYwiRg==}
peerDependencies:
react: '*'
react-native: '*'
- expo@52.0.18:
- resolution: {integrity: sha512-z+qdUbH0d5JRknE3VrY0s5k+3j5JpsLx4vXRwV4To8Xm5uf3d642FQ2HbuPWFAAhtSKFQsxQAh3iuAUGAWDBhg==}
+ expo@52.0.28:
+ resolution: {integrity: sha512-0O/JEYYCFszJ85frislm79YmlrQA5ghAQXV4dqcQcsy9FqftdicD4p/ehT36yiuGIhaKC6fn25LEaJ9JR2ei7g==}
hasBin: true
peerDependencies:
'@expo/dom-webview': '*'
@@ -11589,8 +11615,8 @@ packages:
'@types/react':
optional: true
- react-native@0.76.5:
- resolution: {integrity: sha512-op2p2kB+lqMF1D7AdX4+wvaR0OPFbvWYs+VBE7bwsb99Cn9xISrLRLAgFflZedQsa5HvnOGrULhtnmItbIKVVw==}
+ react-native@0.76.6:
+ resolution: {integrity: sha512-AsRi+ud6v6ADH7ZtSOY42kRB4nbM0KtSu450pGO4pDudl4AEK/AF96ai88snb2/VJJSGGa/49QyJVFXxz/qoFg==}
engines: {node: '>=18'}
hasBin: true
peerDependencies:
@@ -13252,6 +13278,11 @@ packages:
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
+ vite-node@3.0.4:
+ resolution: {integrity: sha512-7JZKEzcYV2Nx3u6rlvN8qdo3QV7Fxyt6hx+CCKz9fbWxdX5IvUOmTWEAxMrWxaiSf7CKGLJQ5rFu8prb/jBjOA==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ hasBin: true
+
vite-node@3.0.5:
resolution: {integrity: sha512-02JEJl7SbtwSDJdYS537nU6l+ktdvcREfLksk/NDAqtdKWGqHl+joXzEubHROmS3E6pip+Xgu2tFezMu75jH7A==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -13382,6 +13413,34 @@ packages:
jsdom:
optional: true
+ vitest@3.0.4:
+ resolution: {integrity: sha512-6XG8oTKy2gnJIFTHP6LD7ExFeNLxiTkK3CfMvT7IfR8IN+BYICCf0lXUQmX7i7JoxUP8QmeP4mTnWXgflu4yjw==}
+ engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
+ hasBin: true
+ peerDependencies:
+ '@edge-runtime/vm': '*'
+ '@types/debug': ^4.1.12
+ '@types/node': ^18.0.0 || ^20.0.0 || >=22.0.0
+ '@vitest/browser': 3.0.4
+ '@vitest/ui': 3.0.4
+ happy-dom: '*'
+ jsdom: '*'
+ peerDependenciesMeta:
+ '@edge-runtime/vm':
+ optional: true
+ '@types/debug':
+ optional: true
+ '@types/node':
+ optional: true
+ '@vitest/browser':
+ optional: true
+ '@vitest/ui':
+ optional: true
+ happy-dom:
+ optional: true
+ jsdom:
+ optional: true
+
vitest@3.0.5:
resolution: {integrity: sha512-4dof+HvqONw9bvsYxtkfUp2uHsTN9bV2CZIi1pWgoFpL1Lld8LA1ka9q/ONSsoScAKG7NVGf2stJTI7XRkXb2Q==}
engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0}
@@ -15514,6 +15573,8 @@ snapshots:
'@bcoe/v8-coverage@0.2.3': {}
+ '@bcoe/v8-coverage@1.0.2': {}
+
'@calcom/embed-core@1.5.1': {}
'@calcom/embed-react@1.5.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)':
@@ -16100,7 +16161,7 @@ snapshots:
dependencies:
uuid: 8.3.2
- '@expo/cli@0.22.5(encoding@0.1.13)':
+ '@expo/cli@0.22.11(encoding@0.1.13)':
dependencies:
'@0no-co/graphql.web': 1.0.13
'@babel/runtime': 7.26.7
@@ -16119,7 +16180,7 @@ snapshots:
'@expo/rudder-sdk-node': 1.1.1(encoding@0.1.13)
'@expo/spawn-async': 1.7.2
'@expo/xcpretty': 4.3.2
- '@react-native/dev-middleware': 0.76.5
+ '@react-native/dev-middleware': 0.76.6
'@urql/core': 5.1.0
'@urql/exchange-retry': 1.3.0(@urql/core@5.1.0)
accepts: 1.3.8
@@ -16251,7 +16312,7 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@expo/fingerprint@0.11.3':
+ '@expo/fingerprint@0.11.7':
dependencies:
'@expo/spawn-async': 1.7.2
arg: 5.0.2
@@ -16285,29 +16346,6 @@ snapshots:
json5: 2.2.3
write-file-atomic: 2.4.3
- '@expo/metro-config@0.19.7':
- dependencies:
- '@babel/core': 7.26.0
- '@babel/generator': 7.26.5
- '@babel/parser': 7.26.7
- '@babel/types': 7.26.7
- '@expo/config': 10.0.8
- '@expo/env': 0.4.1
- '@expo/json-file': 9.0.1
- '@expo/spawn-async': 1.7.2
- chalk: 4.1.2
- debug: 4.4.0
- fs-extra: 9.1.0
- getenv: 1.0.0
- glob: 10.4.5
- jsc-safe-url: 0.2.4
- lightningcss: 1.27.0
- minimatch: 3.1.2
- postcss: 8.4.49
- resolve-from: 5.0.0
- transitivePeerDependencies:
- - supports-color
-
'@expo/metro-config@0.19.9':
dependencies:
'@babel/core': 7.26.0
@@ -18228,6 +18266,11 @@ snapshots:
merge-options: 3.0.4
react-native: 0.74.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@types/react@18.3.11)(encoding@0.1.13)(react@18.3.1)
+ '@react-native-async-storage/async-storage@2.1.0(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))':
+ dependencies:
+ merge-options: 3.0.4
+ react-native: 0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1)
+
'@react-native-community/cli-clean@13.6.9(encoding@0.1.13)':
dependencies:
'@react-native-community/cli-tools': 13.6.9(encoding@0.1.13)
@@ -18377,7 +18420,7 @@ snapshots:
'@react-native/assets-registry@0.74.87': {}
- '@react-native/assets-registry@0.76.5': {}
+ '@react-native/assets-registry@0.76.6': {}
'@react-native/babel-plugin-codegen@0.74.87(@babel/preset-env@7.26.7(@babel/core@7.26.0))':
dependencies:
@@ -18386,13 +18429,6 @@ snapshots:
- '@babel/preset-env'
- supports-color
- '@react-native/babel-plugin-codegen@0.76.5(@babel/preset-env@7.26.7(@babel/core@7.26.0))':
- dependencies:
- '@react-native/codegen': 0.76.5(@babel/preset-env@7.26.7(@babel/core@7.26.0))
- transitivePeerDependencies:
- - '@babel/preset-env'
- - supports-color
-
'@react-native/babel-plugin-codegen@0.76.6(@babel/preset-env@7.26.7(@babel/core@7.26.0))':
dependencies:
'@react-native/codegen': 0.76.6(@babel/preset-env@7.26.7(@babel/core@7.26.0))
@@ -18449,57 +18485,6 @@ snapshots:
- '@babel/preset-env'
- supports-color
- '@react-native/babel-preset@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))':
- dependencies:
- '@babel/core': 7.26.0
- '@babel/plugin-proposal-export-default-from': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-syntax-dynamic-import': 7.8.3(@babel/core@7.26.0)
- '@babel/plugin-syntax-export-default-from': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-syntax-nullish-coalescing-operator': 7.8.3(@babel/core@7.26.0)
- '@babel/plugin-syntax-optional-chaining': 7.8.3(@babel/core@7.26.0)
- '@babel/plugin-transform-arrow-functions': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-async-generator-functions': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-async-to-generator': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-block-scoping': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-class-properties': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-classes': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-computed-properties': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-destructuring': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-flow-strip-types': 7.26.5(@babel/core@7.26.0)
- '@babel/plugin-transform-for-of': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-function-name': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-literals': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-logical-assignment-operators': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-modules-commonjs': 7.26.3(@babel/core@7.26.0)
- '@babel/plugin-transform-named-capturing-groups-regex': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-nullish-coalescing-operator': 7.26.6(@babel/core@7.26.0)
- '@babel/plugin-transform-numeric-separator': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-object-rest-spread': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-optional-catch-binding': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-optional-chaining': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-parameters': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-private-methods': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-private-property-in-object': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-react-display-name': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-react-jsx': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-react-jsx-self': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-react-jsx-source': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-regenerator': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-runtime': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-shorthand-properties': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-spread': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-sticky-regex': 7.25.9(@babel/core@7.26.0)
- '@babel/plugin-transform-typescript': 7.26.7(@babel/core@7.26.0)
- '@babel/plugin-transform-unicode-regex': 7.25.9(@babel/core@7.26.0)
- '@babel/template': 7.25.9
- '@react-native/babel-plugin-codegen': 0.76.5(@babel/preset-env@7.26.7(@babel/core@7.26.0))
- babel-plugin-syntax-hermes-parser: 0.25.1
- babel-plugin-transform-flow-enums: 0.0.2(@babel/core@7.26.0)
- react-refresh: 0.14.2
- transitivePeerDependencies:
- - '@babel/preset-env'
- - supports-color
-
'@react-native/babel-preset@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))':
dependencies:
'@babel/core': 7.26.0
@@ -18564,20 +18549,6 @@ snapshots:
transitivePeerDependencies:
- supports-color
- '@react-native/codegen@0.76.5(@babel/preset-env@7.26.7(@babel/core@7.26.0))':
- dependencies:
- '@babel/parser': 7.26.7
- '@babel/preset-env': 7.26.7(@babel/core@7.26.0)
- glob: 7.2.3
- hermes-parser: 0.23.1
- invariant: 2.2.4
- jscodeshift: 0.14.0(@babel/preset-env@7.26.7(@babel/core@7.26.0))
- mkdirp: 0.5.6
- nullthrows: 1.1.1
- yargs: 17.7.2
- transitivePeerDependencies:
- - supports-color
-
'@react-native/codegen@0.76.6(@babel/preset-env@7.26.7(@babel/core@7.26.0))':
dependencies:
'@babel/parser': 7.26.7
@@ -18614,10 +18585,10 @@ snapshots:
- supports-color
- utf-8-validate
- '@react-native/community-cli-plugin@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(encoding@0.1.13)':
+ '@react-native/community-cli-plugin@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(encoding@0.1.13)':
dependencies:
- '@react-native/dev-middleware': 0.76.5
- '@react-native/metro-babel-transformer': 0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))
+ '@react-native/dev-middleware': 0.76.6
+ '@react-native/metro-babel-transformer': 0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))
chalk: 4.1.2
execa: 5.1.1
invariant: 2.2.4
@@ -18639,7 +18610,7 @@ snapshots:
'@react-native/debugger-frontend@0.74.87': {}
- '@react-native/debugger-frontend@0.76.5': {}
+ '@react-native/debugger-frontend@0.76.6': {}
'@react-native/dev-middleware@0.74.87(encoding@0.1.13)':
dependencies:
@@ -18662,10 +18633,10 @@ snapshots:
- supports-color
- utf-8-validate
- '@react-native/dev-middleware@0.76.5':
+ '@react-native/dev-middleware@0.76.6':
dependencies:
'@isaacs/ttlcache': 1.4.1
- '@react-native/debugger-frontend': 0.76.5
+ '@react-native/debugger-frontend': 0.76.6
chrome-launcher: 0.15.2
chromium-edge-launcher: 0.2.0
connect: 3.7.0
@@ -18682,11 +18653,11 @@ snapshots:
'@react-native/gradle-plugin@0.74.87': {}
- '@react-native/gradle-plugin@0.76.5': {}
+ '@react-native/gradle-plugin@0.76.6': {}
'@react-native/js-polyfills@0.74.87': {}
- '@react-native/js-polyfills@0.76.5': {}
+ '@react-native/js-polyfills@0.76.6': {}
'@react-native/metro-babel-transformer@0.74.87(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))':
dependencies:
@@ -18698,10 +18669,10 @@ snapshots:
- '@babel/preset-env'
- supports-color
- '@react-native/metro-babel-transformer@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))':
+ '@react-native/metro-babel-transformer@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))':
dependencies:
'@babel/core': 7.26.0
- '@react-native/babel-preset': 0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))
+ '@react-native/babel-preset': 0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))
hermes-parser: 0.23.1
nullthrows: 1.1.1
transitivePeerDependencies:
@@ -18710,8 +18681,6 @@ snapshots:
'@react-native/normalize-colors@0.74.87': {}
- '@react-native/normalize-colors@0.76.5': {}
-
'@react-native/normalize-colors@0.76.6': {}
'@react-native/virtualized-lists@0.74.87(@types/react@18.3.11)(react-native@0.74.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@types/react@18.3.11)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)':
@@ -18723,14 +18692,14 @@ snapshots:
optionalDependencies:
'@types/react': 18.3.11
- '@react-native/virtualized-lists@0.76.5(@types/react@19.0.1)(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)':
+ '@react-native/virtualized-lists@0.76.6(@types/react@18.3.18)(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)':
dependencies:
invariant: 2.2.4
nullthrows: 1.1.1
react: 18.3.1
- react-native: 0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1)
+ react-native: 0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1)
optionalDependencies:
- '@types/react': 19.0.1
+ '@types/react': 18.3.18
'@react-stately/utils@3.10.5(react@19.0.0)':
dependencies:
@@ -20069,7 +20038,6 @@ snapshots:
dependencies:
'@types/prop-types': 15.7.14
csstype: 3.1.3
- optional: true
'@types/react@19.0.1':
dependencies:
@@ -20454,6 +20422,24 @@ snapshots:
transitivePeerDependencies:
- supports-color
+ '@vitest/coverage-v8@3.0.4(vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.4.1)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0))':
+ dependencies:
+ '@ampproject/remapping': 2.3.0
+ '@bcoe/v8-coverage': 1.0.2
+ debug: 4.4.0
+ istanbul-lib-coverage: 3.2.2
+ istanbul-lib-report: 3.0.1
+ istanbul-lib-source-maps: 5.0.6
+ istanbul-reports: 3.1.7
+ magic-string: 0.30.17
+ magicast: 0.3.5
+ std-env: 3.8.0
+ test-exclude: 7.0.1
+ tinyrainbow: 2.0.0
+ vitest: 3.0.4(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.4.1)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0)
+ transitivePeerDependencies:
+ - supports-color
+
'@vitest/expect@2.0.5':
dependencies:
'@vitest/spy': 2.0.5
@@ -20468,6 +20454,13 @@ snapshots:
chai: 5.1.2
tinyrainbow: 1.2.0
+ '@vitest/expect@3.0.4':
+ dependencies:
+ '@vitest/spy': 3.0.4
+ '@vitest/utils': 3.0.4
+ chai: 5.1.2
+ tinyrainbow: 2.0.0
+
'@vitest/expect@3.0.5':
dependencies:
'@vitest/spy': 3.0.5
@@ -20483,6 +20476,14 @@ snapshots:
optionalDependencies:
vite: 5.4.14(@types/node@22.10.2)(lightningcss@1.27.0)(terser@5.37.0)
+ '@vitest/mocker@3.0.4(vite@6.0.9(@types/node@22.10.2)(jiti@2.4.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0))':
+ dependencies:
+ '@vitest/spy': 3.0.4
+ estree-walker: 3.0.3
+ magic-string: 0.30.17
+ optionalDependencies:
+ vite: 6.0.9(@types/node@22.10.2)(jiti@2.4.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0)
+
'@vitest/mocker@3.0.5(vite@6.0.9(@types/node@22.10.2)(jiti@2.4.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0))':
dependencies:
'@vitest/spy': 3.0.5
@@ -20499,6 +20500,10 @@ snapshots:
dependencies:
tinyrainbow: 1.2.0
+ '@vitest/pretty-format@3.0.4':
+ dependencies:
+ tinyrainbow: 2.0.0
+
'@vitest/pretty-format@3.0.5':
dependencies:
tinyrainbow: 2.0.0
@@ -20508,6 +20513,11 @@ snapshots:
'@vitest/utils': 2.1.8
pathe: 1.1.2
+ '@vitest/runner@3.0.4':
+ dependencies:
+ '@vitest/utils': 3.0.4
+ pathe: 2.0.2
+
'@vitest/runner@3.0.5':
dependencies:
'@vitest/utils': 3.0.5
@@ -20519,6 +20529,12 @@ snapshots:
magic-string: 0.30.17
pathe: 1.1.2
+ '@vitest/snapshot@3.0.4':
+ dependencies:
+ '@vitest/pretty-format': 3.0.4
+ magic-string: 0.30.17
+ pathe: 2.0.2
+
'@vitest/snapshot@3.0.5':
dependencies:
'@vitest/pretty-format': 3.0.5
@@ -20533,6 +20549,10 @@ snapshots:
dependencies:
tinyspy: 3.0.2
+ '@vitest/spy@3.0.4':
+ dependencies:
+ tinyspy: 3.0.2
+
'@vitest/spy@3.0.5':
dependencies:
tinyspy: 3.0.2
@@ -20550,6 +20570,12 @@ snapshots:
loupe: 3.1.3
tinyrainbow: 1.2.0
+ '@vitest/utils@3.0.4':
+ dependencies:
+ '@vitest/pretty-format': 3.0.4
+ loupe: 3.1.3
+ tinyrainbow: 2.0.0
+
'@vitest/utils@3.0.5':
dependencies:
'@vitest/pretty-format': 3.0.5
@@ -23022,45 +23048,45 @@ snapshots:
expect-type@1.1.0: {}
- expo-asset@11.0.2(expo@52.0.18(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1):
+ expo-asset@11.0.2(expo@52.0.28(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1):
dependencies:
'@expo/image-utils': 0.6.4
- expo: 52.0.18(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
- expo-constants: 17.0.5(expo@52.0.18(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))
+ expo: 52.0.28(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
+ expo-constants: 17.0.5(expo@52.0.28(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))
invariant: 2.2.4
md5-file: 3.2.3
react: 18.3.1
- react-native: 0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1)
+ react-native: 0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1)
transitivePeerDependencies:
- supports-color
- expo-constants@17.0.5(expo@52.0.18(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1)):
+ expo-constants@17.0.5(expo@52.0.28(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1)):
dependencies:
'@expo/config': 10.0.8
'@expo/env': 0.4.1
- expo: 52.0.18(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
- react-native: 0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1)
+ expo: 52.0.28(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
+ react-native: 0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1)
transitivePeerDependencies:
- supports-color
- expo-file-system@18.0.7(expo@52.0.18(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1)):
+ expo-file-system@18.0.7(expo@52.0.28(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1)):
dependencies:
- expo: 52.0.18(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
- react-native: 0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1)
+ expo: 52.0.28(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
+ react-native: 0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1)
web-streams-polyfill: 3.3.3
- expo-font@13.0.3(expo@52.0.18(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react@18.3.1):
+ expo-font@13.0.3(expo@52.0.28(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react@18.3.1):
dependencies:
- expo: 52.0.18(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
+ expo: 52.0.28(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
fontfaceobserver: 2.3.0
react: 18.3.1
- expo-keep-awake@14.0.2(expo@52.0.18(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react@18.3.1):
+ expo-keep-awake@14.0.2(expo@52.0.28(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react@18.3.1):
dependencies:
- expo: 52.0.18(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
+ expo: 52.0.28(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
react: 18.3.1
- expo-modules-autolinking@2.0.4:
+ expo-modules-autolinking@2.0.7:
dependencies:
'@expo/spawn-async': 1.7.2
chalk: 4.1.2
@@ -23071,39 +23097,39 @@ snapshots:
require-from-string: 2.0.2
resolve-from: 5.0.0
- expo-modules-core@2.1.1:
+ expo-modules-core@2.2.0:
dependencies:
invariant: 2.2.4
- expo-status-bar@2.0.0(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1):
+ expo-status-bar@2.0.1(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1):
dependencies:
react: 18.3.1
- react-native: 0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1)
+ react-native: 0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1)
- expo@52.0.18(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1):
+ expo@52.0.28(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1):
dependencies:
'@babel/runtime': 7.26.7
- '@expo/cli': 0.22.5(encoding@0.1.13)
+ '@expo/cli': 0.22.11(encoding@0.1.13)
'@expo/config': 10.0.8
'@expo/config-plugins': 9.0.14
- '@expo/fingerprint': 0.11.3
- '@expo/metro-config': 0.19.7
+ '@expo/fingerprint': 0.11.7
+ '@expo/metro-config': 0.19.9
'@expo/vector-icons': 14.0.4
babel-preset-expo: 12.0.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))
- expo-asset: 11.0.2(expo@52.0.18(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
- expo-constants: 17.0.5(expo@52.0.18(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))
- expo-file-system: 18.0.7(expo@52.0.18(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))
- expo-font: 13.0.3(expo@52.0.18(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react@18.3.1)
- expo-keep-awake: 14.0.2(expo@52.0.18(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react@18.3.1)
- expo-modules-autolinking: 2.0.4
- expo-modules-core: 2.1.1
+ expo-asset: 11.0.2(expo@52.0.28(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
+ expo-constants: 17.0.5(expo@52.0.28(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))
+ expo-file-system: 18.0.7(expo@52.0.28(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))
+ expo-font: 13.0.3(expo@52.0.28(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react@18.3.1)
+ expo-keep-awake: 14.0.2(expo@52.0.28(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(encoding@0.1.13)(react-native-webview@13.12.5(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1))(react@18.3.1)
+ expo-modules-autolinking: 2.0.7
+ expo-modules-core: 2.2.0
fbemitter: 3.0.0(encoding@0.1.13)
react: 18.3.1
- react-native: 0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1)
+ react-native: 0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1)
web-streams-polyfill: 3.3.3
whatwg-url-without-unicode: 8.0.0-3
optionalDependencies:
- react-native-webview: 13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
+ react-native-webview: 13.12.5(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
transitivePeerDependencies:
- '@babel/core'
- '@babel/preset-env'
@@ -27070,12 +27096,12 @@ snapshots:
react: 18.3.1
react-native: 0.74.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@types/react@18.3.11)(encoding@0.1.13)(react@18.3.1)
- react-native-webview@13.12.5(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1):
+ react-native-webview@13.12.5(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1):
dependencies:
escape-string-regexp: 4.0.0
invariant: 2.2.4
react: 18.3.1
- react-native: 0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1)
+ react-native: 0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1)
react-native@0.74.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@types/react@18.3.11)(encoding@0.1.13)(react@18.3.1):
dependencies:
@@ -27127,16 +27153,16 @@ snapshots:
- supports-color
- utf-8-validate
- react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1):
+ react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1):
dependencies:
'@jest/create-cache-key-function': 29.7.0
- '@react-native/assets-registry': 0.76.5
- '@react-native/codegen': 0.76.5(@babel/preset-env@7.26.7(@babel/core@7.26.0))
- '@react-native/community-cli-plugin': 0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(encoding@0.1.13)
- '@react-native/gradle-plugin': 0.76.5
- '@react-native/js-polyfills': 0.76.5
- '@react-native/normalize-colors': 0.76.5
- '@react-native/virtualized-lists': 0.76.5(@types/react@19.0.1)(react-native@0.76.5(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@19.0.1)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
+ '@react-native/assets-registry': 0.76.6
+ '@react-native/codegen': 0.76.6(@babel/preset-env@7.26.7(@babel/core@7.26.0))
+ '@react-native/community-cli-plugin': 0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(encoding@0.1.13)
+ '@react-native/gradle-plugin': 0.76.6
+ '@react-native/js-polyfills': 0.76.6
+ '@react-native/normalize-colors': 0.76.6
+ '@react-native/virtualized-lists': 0.76.6(@types/react@18.3.18)(react-native@0.76.6(@babel/core@7.26.0)(@babel/preset-env@7.26.7(@babel/core@7.26.0))(@react-native-community/cli-server-api@13.6.9(encoding@0.1.13))(@types/react@18.3.18)(encoding@0.1.13)(react@18.3.1))(react@18.3.1)
abort-controller: 3.0.0
anser: 1.4.10
ansi-regex: 5.0.1
@@ -27169,7 +27195,7 @@ snapshots:
ws: 6.2.3
yargs: 17.7.2
optionalDependencies:
- '@types/react': 19.0.1
+ '@types/react': 18.3.18
transitivePeerDependencies:
- '@babel/core'
- '@babel/preset-env'
@@ -29123,6 +29149,27 @@ snapshots:
- supports-color
- terser
+ vite-node@3.0.4(@types/node@22.10.2)(jiti@2.4.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0):
+ dependencies:
+ cac: 6.7.14
+ debug: 4.4.0
+ es-module-lexer: 1.6.0
+ pathe: 2.0.2
+ vite: 6.0.9(@types/node@22.10.2)(jiti@2.4.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0)
+ transitivePeerDependencies:
+ - '@types/node'
+ - jiti
+ - less
+ - lightningcss
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+ - tsx
+ - yaml
+
vite-node@3.0.5(@types/node@22.10.2)(jiti@2.4.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0):
dependencies:
cac: 6.7.14
@@ -29255,6 +29302,46 @@ snapshots:
- supports-color
- terser
+ vitest@3.0.4(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.4.1)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0):
+ dependencies:
+ '@vitest/expect': 3.0.4
+ '@vitest/mocker': 3.0.4(vite@6.0.9(@types/node@22.10.2)(jiti@2.4.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0))
+ '@vitest/pretty-format': 3.0.5
+ '@vitest/runner': 3.0.4
+ '@vitest/snapshot': 3.0.4
+ '@vitest/spy': 3.0.4
+ '@vitest/utils': 3.0.4
+ chai: 5.1.2
+ debug: 4.4.0
+ expect-type: 1.1.0
+ magic-string: 0.30.17
+ pathe: 2.0.2
+ std-env: 3.8.0
+ tinybench: 2.9.0
+ tinyexec: 0.3.2
+ tinypool: 1.0.2
+ tinyrainbow: 2.0.0
+ vite: 6.0.9(@types/node@22.10.2)(jiti@2.4.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0)
+ vite-node: 3.0.4(@types/node@22.10.2)(jiti@2.4.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0)
+ why-is-node-running: 2.3.0
+ optionalDependencies:
+ '@types/debug': 4.1.12
+ '@types/node': 22.10.2
+ jsdom: 25.0.1
+ transitivePeerDependencies:
+ - jiti
+ - less
+ - lightningcss
+ - msw
+ - sass
+ - sass-embedded
+ - stylus
+ - sugarss
+ - supports-color
+ - terser
+ - tsx
+ - yaml
+
vitest@3.0.5(@types/debug@4.1.12)(@types/node@22.10.2)(jiti@2.4.1)(jsdom@25.0.1)(lightningcss@1.27.0)(terser@5.37.0)(tsx@4.19.2)(yaml@2.7.0):
dependencies:
'@vitest/expect': 3.0.5
diff --git a/turbo.json b/turbo.json
index 32d10489ce..f74b80a5fe 100644
--- a/turbo.json
+++ b/turbo.json
@@ -49,6 +49,9 @@
"@formbricks/react-native#lint": {
"dependsOn": ["@formbricks/api#build"]
},
+ "@formbricks/react-native#test": {
+ "dependsOn": ["@formbricks/api#build"]
+ },
"@formbricks/surveys#build": {
"dependsOn": ["^build"],
"outputs": ["dist/**"]