diff --git a/package.json b/package.json index 3a21cc6..970b2a8 100644 --- a/package.json +++ b/package.json @@ -11,7 +11,8 @@ "devDependencies": { "prettier": "^3.5.3", "turbo": "^2.5.2", - "typescript": "5.8.2" + "typescript": "5.8.2", + "rimraf": "6.0.1" }, "packageManager": "pnpm@9.0.0", "engines": { diff --git a/packages/react-native/src/components/survey-web-view.tsx b/packages/react-native/src/components/survey-web-view.tsx index e4fffe8..27c99c7 100644 --- a/packages/react-native/src/components/survey-web-view.tsx +++ b/packages/react-native/src/components/survey-web-view.tsx @@ -9,7 +9,6 @@ import React, { type JSX, useEffect, useRef, useState } from "react"; import { KeyboardAvoidingView, Modal, View, StyleSheet } from "react-native"; import { WebView, type WebViewMessageEvent } from "react-native-webview"; -const appConfig = RNConfig.getInstance(); const logger = Logger.getInstance(); logger.configure({ logLevel: "debug" }); @@ -25,16 +24,27 @@ export function SurveyWebView( const webViewRef = useRef(null); const [isSurveyRunning, setIsSurveyRunning] = useState(false); const [showSurvey, setShowSurvey] = useState(false); + const [appConfig, setAppConfig] = useState(null); + const [languageCode, setLanguageCode] = useState("default"); - const project = appConfig.get().environment.data.project; - const language = appConfig.get().user.data.language; + useEffect(() => { + const fetchConfig = async () => { + const config = await RNConfig.getInstance(); + setAppConfig(config); + }; + + void fetchConfig(); + }, []); - const styling = getStyling(project, props.survey); - const isBrandingEnabled = project.inAppSurveyBranding; const isMultiLanguageSurvey = props.survey.languages.length > 1; - const [languageCode, setLanguageCode] = useState("default"); useEffect(() => { + if (!appConfig) { + return; + } + + const language = appConfig.get().user.data.language; + if (isMultiLanguageSurvey) { const displayLanguage = getLanguageCode(props.survey, language); if (!displayLanguage) { @@ -51,7 +61,7 @@ export function SurveyWebView( } else { setIsSurveyRunning(true); } - }, [isMultiLanguageSurvey, language, props.survey]); + }, [isMultiLanguageSurvey, props.survey, appConfig]); useEffect(() => { if (!isSurveyRunning) { @@ -75,6 +85,14 @@ export function SurveyWebView( setShowSurvey(true); }, [props.survey.delay, isSurveyRunning, props.survey.name]); + if (!appConfig) { + return; + } + + const project = appConfig.get().environment.data.project; + const styling = getStyling(project, props.survey); + const isBrandingEnabled = project.inAppSurveyBranding; + const onCloseSurvey = (): void => { const { environment: environmentState, user: personState } = appConfig.get(); diff --git a/packages/react-native/src/lib/common/api.ts b/packages/react-native/src/lib/common/api.ts index ff1a664..02b92f1 100644 --- a/packages/react-native/src/lib/common/api.ts +++ b/packages/react-native/src/lib/common/api.ts @@ -1,5 +1,9 @@ import { wrapThrowsAsync } from "@/lib/common/utils"; -import { ApiResponse, ApiSuccessResponse, CreateOrUpdateUserResponse } from "@/types/api"; +import { + ApiResponse, + ApiSuccessResponse, + CreateOrUpdateUserResponse, +} from "@/types/api"; import { TEnvironmentState } from "@/types/config"; import { ApiErrorResponse, Result, err, ok } from "@/types/error"; @@ -40,7 +44,9 @@ export const makeRequest = async ( status: response.status, message: errorResponse.message || "Something went wrong", url, - ...(Object.keys(errorResponse.details ?? {}).length > 0 && { details: errorResponse.details }), + ...(Object.keys(errorResponse.details ?? {}).length > 0 && { + details: errorResponse.details, + }), }); } @@ -50,9 +56,9 @@ export const makeRequest = async ( // Simple API client using fetch export class ApiClient { - private appUrl: string; - private environmentId: string; - private isDebug: boolean; + private readonly appUrl: string; + private readonly environmentId: string; + private readonly isDebug: boolean; constructor({ appUrl, @@ -90,7 +96,9 @@ export class ApiClient { ); } - async getEnvironmentState(): Promise> { + async getEnvironmentState(): Promise< + Result + > { return makeRequest( this.appUrl, `/api/v1/client/${this.environmentId}/environment`, diff --git a/packages/react-native/src/lib/common/command-queue.ts b/packages/react-native/src/lib/common/command-queue.ts index a0ce884..9d94922 100644 --- a/packages/react-native/src/lib/common/command-queue.ts +++ b/packages/react-native/src/lib/common/command-queue.ts @@ -4,8 +4,10 @@ import { wrapThrowsAsync } from "@/lib/common/utils"; import type { Result } from "@/types/error"; export class CommandQueue { - private queue: { - command: (...args: any[]) => Promise> | Result | Promise; + private readonly queue: { + command: ( + ...args: any[] + ) => Promise> | Result | Promise; checkSetup: boolean; commandArgs: any[]; }[] = []; @@ -14,11 +16,17 @@ export class CommandQueue { private commandPromise: Promise | null = null; public add( - command: (...args: A[]) => Promise> | Result | Promise, + command: ( + ...args: A[] + ) => Promise> | Result | Promise, shouldCheckSetup = true, ...args: A[] ): void { - this.queue.push({ command, checkSetup: shouldCheckSetup, commandArgs: args }); + this.queue.push({ + command, + checkSetup: shouldCheckSetup, + commandArgs: args, + }); if (!this.running) { this.commandPromise = new Promise((resolve) => { @@ -52,7 +60,10 @@ export class CommandQueue { } const executeCommand = async (): Promise> => { - return (await currentItem.command.apply(null, currentItem.commandArgs)) as Result; + return (await currentItem.command.apply( + null, + currentItem.commandArgs + )) as Result; }; const result = await wrapThrowsAsync(executeCommand)(); diff --git a/packages/react-native/src/lib/common/config.ts b/packages/react-native/src/lib/common/config.ts index 6da3f0e..a81c796 100644 --- a/packages/react-native/src/lib/common/config.ts +++ b/packages/react-native/src/lib/common/config.ts @@ -11,23 +11,22 @@ export class RNConfig { private config: TConfig | null = null; - private constructor() { - this.loadFromStorage() - .then((localConfig) => { - if (localConfig.ok) { - this.config = localConfig.data; - } - }) - .catch((e: unknown) => { - console.error("Error loading config from storage", e); - }); - } + private constructor() {} - static getInstance(): RNConfig { - if (!RNConfig.instance) { - RNConfig.instance = new RNConfig(); + public async init(): Promise { + try { + const localConfig = await this.loadFromStorage(); + if (localConfig.ok) { + this.config = localConfig.data; + } + } catch (e: unknown) { + console.error("Error loading config from storage", e); } + } + static async getInstance(): Promise { + RNConfig.instance ??= new RNConfig(); + await RNConfig.instance.init(); return RNConfig.instance; } @@ -46,7 +45,9 @@ export class RNConfig { public get(): TConfig { if (!this.config) { - throw new Error("config is null, maybe the init function was not called?"); + throw new Error( + "config is null, maybe the init function was not called?" + ); } return this.config; } @@ -77,7 +78,10 @@ export class RNConfig { private async saveToStorage(): Promise> { return wrapThrowsAsync(async () => { - await AsyncStorage.setItem(RN_ASYNC_STORAGE_KEY, JSON.stringify(this.config)); + await AsyncStorage.setItem( + RN_ASYNC_STORAGE_KEY, + JSON.stringify(this.config) + ); })(); } diff --git a/packages/react-native/src/lib/common/file-upload.ts b/packages/react-native/src/lib/common/file-upload.ts index a4fb21d..78d4907 100644 --- a/packages/react-native/src/lib/common/file-upload.ts +++ b/packages/react-native/src/lib/common/file-upload.ts @@ -1,9 +1,12 @@ /* eslint-disable no-console -- used for error logging */ -import { type TUploadFileConfig, type TUploadFileResponse } from "@/types/storage"; +import { + type TUploadFileConfig, + type TUploadFileResponse, +} from "@/types/storage"; export class StorageAPI { - private appUrl: string; - private environmentId: string; + private readonly appUrl: string; + private readonly environmentId: string; constructor(appUrl: string, environmentId: string) { this.appUrl = appUrl; @@ -29,13 +32,16 @@ export class StorageAPI { surveyId, }; - const response = await fetch(`${this.appUrl}/api/v1/client/${this.environmentId}/storage`, { - method: "POST", - headers: { - "Content-Type": "application/json", - }, - body: JSON.stringify(payload), - }); + const response = await fetch( + `${this.appUrl}/api/v1/client/${this.environmentId}/storage`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(payload), + } + ); if (!response.ok) { throw new Error(`Upload failed with status: ${String(response.status)}`); @@ -45,7 +51,13 @@ export class StorageAPI { const { data } = json; - const { signedUrl, fileUrl, signingData, presignedFields, updatedFileName } = data; + const { + signedUrl, + fileUrl, + signingData, + presignedFields, + updatedFileName, + } = data; let localUploadDetails: Record = {}; @@ -86,7 +98,10 @@ export class StorageAPI { let uploadResponse: Response = {} as Response; - const signedUrlCopy = signedUrl.replace("http://localhost:3000", this.appUrl); + const signedUrlCopy = signedUrl.replace( + "http://localhost:3000", + this.appUrl + ); try { uploadResponse = await fetch(signedUrlCopy, { @@ -114,12 +129,16 @@ export class StorageAPI { // if s3 is used, we'll use the text response: const errorText = await uploadResponse.text(); if (presignedFields && errorText.includes("EntityTooLarge")) { - const error = new Error("File size exceeds the size limit for your plan"); + const error = new Error( + "File size exceeds the size limit for your plan" + ); error.name = "FileTooLargeError"; throw error; } - throw new Error(`Upload failed with status: ${String(uploadResponse.status)}`); + throw new Error( + `Upload failed with status: ${String(uploadResponse.status)}` + ); } return fileUrl; diff --git a/packages/react-native/src/lib/common/logger.ts b/packages/react-native/src/lib/common/logger.ts index 5d45ffe..2b6bef1 100644 --- a/packages/react-native/src/lib/common/logger.ts +++ b/packages/react-native/src/lib/common/logger.ts @@ -10,9 +10,7 @@ export class Logger { private logLevel: LogLevel = "error"; static getInstance(): Logger { - if (!Logger.instance) { - Logger.instance = new Logger(); - } + Logger.instance ??= new Logger(); return Logger.instance; } diff --git a/packages/react-native/src/lib/common/setup.ts b/packages/react-native/src/lib/common/setup.ts index 5779624..8a4d213 100644 --- a/packages/react-native/src/lib/common/setup.ts +++ b/packages/react-native/src/lib/common/setup.ts @@ -6,11 +6,20 @@ import { } 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 { + 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 TConfig, + type TConfigInput, + type TEnvironmentState, + type TUserState, +} from "@/types/config"; import { type MissingFieldError, type MissingPersonError, @@ -18,6 +27,7 @@ import { type NotSetupError, type Result, err, + ok, okVoid, } from "@/types/error"; @@ -27,7 +37,9 @@ export const setIsSetup = (state: boolean): void => { isSetup = state; }; -export const migrateUserStateAddContactId = async (): Promise<{ changed: boolean }> => { +export const migrateUserStateAddContactId = async (): Promise<{ + changed: boolean; +}> => { const existingConfigString = await AsyncStorage.getItem(RN_ASYNC_STORAGE_KEY); if (existingConfigString) { @@ -39,7 +51,10 @@ export const migrateUserStateAddContactId = async (): Promise<{ changed: boolean } // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition -- data could be undefined - if (!existingConfig.user?.data?.contactId && existingConfig.user?.data?.userId) { + if ( + !existingConfig.user?.data?.contactId && + existingConfig.user?.data?.userId + ) { return { changed: true }; } } @@ -47,205 +62,297 @@ export const migrateUserStateAddContactId = async (): Promise<{ changed: boolean return { changed: false }; }; -export const setup = async ( - configInput: TConfigInput -): Promise> => { - let appConfig = RNConfig.getInstance(); +// Helper: Handle missing field error +function handleMissingField(field: string) { const logger = Logger.getInstance(); + logger.debug(`No ${field} provided`); + return err({ + code: "missing_field", + field, + } as const); +} + +// Helper: Sync environment state if expired +async function syncEnvironmentStateIfExpired( + configInput: TConfigInput, + logger: ReturnType, + existingConfig?: TConfig +): Promise> { + if (existingConfig && !isNowExpired(existingConfig.environment.expiresAt)) { + return ok(existingConfig.environment); + } - const { changed } = await migrateUserStateAddContactId(); + logger.debug("Environment state expired. Syncing."); - if (changed) { - await appConfig.resetConfig(); - appConfig = RNConfig.getInstance(); + const environmentStateResponse = await fetchEnvironmentState({ + appUrl: configInput.appUrl, + environmentId: configInput.environmentId, + }); + + if (environmentStateResponse.ok) { + return ok(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, + }); + } +} + +// Helper: Sync user state if expired +async function syncUserStateIfExpired( + configInput: TConfigInput, + logger: ReturnType, + existingConfig?: TConfig +): Promise> { + const userState = existingConfig?.user; + if (userState && !userState.expiresAt) { + return ok(userState); } - if (isSetup) { - logger.debug("Already set up, skipping setup."); - return okVoid(); + if (userState?.expiresAt && !isNowExpired(userState.expiresAt)) { + return ok(userState); } - let existingConfig: TConfig | undefined; + logger.debug("Person state expired. Syncing."); + + if (userState?.data?.userId) { + const updatesResponse = await sendUpdatesToBackend({ + appUrl: configInput.appUrl, + environmentId: configInput.environmentId, + updates: { + userId: userState.data.userId, + }, + }); + if (updatesResponse.ok) { + return ok(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", + } as const); + } + } else { + return ok(DEFAULT_USER_STATE_NO_USER_ID); + } +} + +// Helper: Update app config with synced states +const updateAppConfigWithSyncedStates = ( + appConfig: RNConfig, + environmentState: TEnvironmentState, + userState: TUserState, + logger: ReturnType, + existingConfig?: TConfig +): void => { + if (!existingConfig) { + return; + } + + const filteredSurveys = filterSurveys(environmentState, userState); + + 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(", ")}` + ); +}; + +// Helper: Create new config and sync +const createNewConfigAndSync = async ( + appConfig: RNConfig, + configInput: TConfigInput, + logger: ReturnType +): Promise => { + logger.debug( + "No valid configuration found. Resetting config and creating new one." + ); + + await appConfig.resetConfig(); + logger.debug("Syncing."); + try { - existingConfig = appConfig.get(); - logger.debug("Found existing configuration."); - } catch { - logger.debug("No existing configuration found."); + const environmentStateResponse = await fetchEnvironmentState({ + appUrl: configInput.appUrl, + environmentId: configInput.environmentId, + }); + if (!environmentStateResponse.ok) { + 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 handleErrorOnFirstSetup( + e as { code: string; responseMessage: string } + ); } +}; - // formbricks is in error state, skip setup +// Helper: Should sync config +const shouldSyncConfig = ( + existingConfig: TConfig | undefined, + configInput: TConfigInput +): boolean => { + return Boolean( + existingConfig?.environment && + existingConfig.environmentId === configInput.environmentId && + existingConfig.appUrl === configInput.appUrl + ); +}; + +// Helper: Should return early for error state +const shouldReturnEarlyForErrorState = ( + existingConfig: TConfig | undefined, + logger: ReturnType +): boolean => { 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 setup"); - return okVoid(); + return true; } logger.debug("Error state is expired. Continue with setup."); } - logger.debug("Start setup"); - - if (!configInput.environmentId) { - logger.debug("No environmentId provided"); - return err({ - code: "missing_field", - field: "environmentId", - }); - } + return false; +}; - if (!configInput.appUrl) { - logger.debug("No appUrl provided"); +// Helper: Add event listeners and finalize setup +const finalizeSetup = (): void => { + const logger = Logger.getInstance(); + logger.debug("Adding event listeners"); + addEventListeners(); + addCleanupEventListeners(); + setIsSetup(true); + logger.debug("Set up complete"); +}; - return err({ - code: "missing_field", - field: "appUrl", - }); +// Helper: Load existing config +const loadExistingConfig = async ( + appConfig: RNConfig, + logger: ReturnType +): Promise => { + let existingConfig: TConfig | undefined; + try { + existingConfig = appConfig.get(); + logger.debug("Found existing configuration."); + } catch { + logger.debug("No existing configuration found."); } + return existingConfig; +}; - if ( - existingConfig?.environment && - existingConfig.environmentId === configInput.environmentId && - existingConfig.appUrl === configInput.appUrl - ) { - logger.debug("Configuration fits setup parameters."); - let isEnvironmentStateExpired = false; - let isUserStateExpired = false; +export const setup = async ( + configInput: TConfigInput +): Promise< + Result +> => { + let appConfig = await RNConfig.getInstance(); - if (isNowExpired(existingConfig.environment.expiresAt)) { - logger.debug("Environment state expired. Syncing."); - isEnvironmentStateExpired = true; - } + const logger = Logger.getInstance(); + const { changed } = await migrateUserStateAddContactId(); - if (existingConfig.user.expiresAt && isNowExpired(existingConfig.user.expiresAt)) { - logger.debug("Person state expired. Syncing."); - isUserStateExpired = true; - } + if (changed) { + await appConfig.resetConfig(); + appConfig = await RNConfig.getInstance(); + } - 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 (isSetup) { + logger.debug("Already set up, skipping setup."); + return okVoid(); + } - 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; - } - } + const existingConfig = await loadExistingConfig(appConfig, logger); + if (shouldReturnEarlyForErrorState(existingConfig, logger)) { + return okVoid(); + } - // filter the environment state wrt the person state - const filteredSurveys = filterSurveys(environmentState, userState); + logger.debug("Start setup"); - // update the appConfig with the new filtered surveys and person state - appConfig.update({ - ...existingConfig, - environment: environmentState, - user: userState, - filteredSurveys, - }); + if (!configInput.environmentId) { + return handleMissingField("environmentId"); + } - 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."); + if (!configInput.appUrl) { + return handleMissingField("appUrl"); + } - // During setup, 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. + if (shouldSyncConfig(existingConfig, configInput)) { + logger.debug("Configuration fits setup parameters."); + let environmentState: TEnvironmentState | undefined; + let userState: TUserState | undefined; 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 environmentStateResult = await syncEnvironmentStateIfExpired( + configInput, + logger, + existingConfig + ); + + if (environmentStateResult.ok) { + environmentState = environmentStateResult.data; + } else { + return err(environmentStateResult.error); } - const personState = DEFAULT_USER_STATE_NO_USER_ID; - const environmentState = environmentStateResponse.data; + const userStateResult = await syncUserStateIfExpired( + configInput, + logger, + existingConfig + ); - const filteredSurveys = filterSurveys(environmentState, personState); + if (userStateResult.ok) { + userState = userStateResult.data; + } else { + return err(userStateResult.error); + } - appConfig.update({ - appUrl: configInput.appUrl, - environmentId: configInput.environmentId, - user: personState, - environment: environmentState, - filteredSurveys, - }); - } catch (e) { - await handleErrorOnFirstSetup(e as { code: string; responseMessage: string }); + updateAppConfigWithSyncedStates( + appConfig, + environmentState, + userState, + logger, + existingConfig + ); + } catch { + logger.debug("Error during sync. Please try again."); } + } else { + await createNewConfigAndSync(appConfig, configInput, logger); } - - logger.debug("Adding event listeners"); - addEventListeners(); - addCleanupEventListeners(); - - setIsSetup(true); - logger.debug("Set up complete"); - - // check page url if set up after page load + finalizeSetup(); return okVoid(); }; @@ -266,16 +373,24 @@ export const checkSetup = (): Result => { // eslint-disable-next-line @typescript-eslint/require-await -- disabled for now export const tearDown = async (): Promise => { const logger = Logger.getInstance(); - const appConfig = RNConfig.getInstance(); + const appConfig = await RNConfig.getInstance(); logger.debug("Setting user state to default"); + + const { environment } = appConfig.get(); + + const filteredSurveys = filterSurveys( + environment, + DEFAULT_USER_STATE_NO_USER_ID + ); + // clear the user state and set it to the default value appConfig.update({ ...appConfig.get(), user: DEFAULT_USER_STATE_NO_USER_ID, + filteredSurveys, }); - setIsSetup(false); removeAllEventListeners(); }; @@ -288,7 +403,9 @@ export const handleErrorOnFirstSetup = async (e: { if (e.code === "forbidden") { logger.error(`Authorization error: ${e.responseMessage}`); } else { - logger.error(`Error during first setup: ${e.code} - ${e.responseMessage}. Please try again later.`); + logger.error( + `Error during first setup: ${e.code} - ${e.responseMessage}. Please try again later.` + ); } // put formbricks in error state (by creating a new config) and throw error @@ -300,7 +417,10 @@ export const handleErrorOnFirstSetup = async (e: { }; await wrapThrowsAsync(async () => { - await AsyncStorage.setItem(RN_ASYNC_STORAGE_KEY, JSON.stringify(initialErrorConfig)); + await AsyncStorage.setItem( + RN_ASYNC_STORAGE_KEY, + JSON.stringify(initialErrorConfig) + ); })(); throw new Error("Could not set up formbricks"); diff --git a/packages/react-native/src/lib/common/storage.ts b/packages/react-native/src/lib/common/storage.ts index a3a4eca..8cd6683 100644 --- a/packages/react-native/src/lib/common/storage.ts +++ b/packages/react-native/src/lib/common/storage.ts @@ -1,9 +1,7 @@ import AsyncStorageModule from "@react-native-async-storage/async-storage"; -const AsyncStorageWithDefault = AsyncStorageModule as typeof AsyncStorageModule & { - default?: typeof AsyncStorageModule; -}; - -const AsyncStorage = AsyncStorageWithDefault.default ?? AsyncStorageModule; +const AsyncStorage = + // @ts-expect-error: Some bundlers put the module on .default + AsyncStorageModule.default ?? AsyncStorageModule; export { AsyncStorage }; 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 index 89b273f..f5685fd 100644 --- a/packages/react-native/src/lib/common/tests/command-queue.test.ts +++ b/packages/react-native/src/lib/common/tests/command-queue.test.ts @@ -2,6 +2,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { CommandQueue } from "@/lib/common/command-queue"; import { checkSetup } from "@/lib/common/setup"; import { type Result } from "@/types/error"; +import { delayedResult } from "../utils"; // Mock the setup module so we can control checkSetup() vi.mock("@/lib/common/setup", () => ({ @@ -23,28 +24,16 @@ describe("CommandQueue", () => { // 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); - }); + executionOrder.push("A"); + return delayedResult({ 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); - }); + executionOrder.push("B"); + return delayedResult({ 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); - }); + executionOrder.push("C"); + return delayedResult({ ok: true, data: undefined }, 10); }); // We'll assume checkSetup always ok for this test @@ -63,11 +52,7 @@ describe("CommandQueue", () => { test("skips execution if checkSetup() fails", async () => { const cmd = vi.fn(async (): Promise => { - return new Promise((resolve) => { - setTimeout(() => { - resolve(); - }, 10); - }); + return delayedResult(undefined, 10); }); // Force checkSetup to fail @@ -88,11 +73,7 @@ describe("CommandQueue", () => { test("executes command if checkSetup is false (no check)", async () => { const cmd = vi.fn(async (): Promise> => { - return new Promise((resolve) => { - setTimeout(() => { - resolve({ ok: true, data: undefined }); - }, 10); - }); + return delayedResult({ ok: true, data: undefined }, 10); }); // checkSetup is irrelevant in this scenario, but let's mock it anyway @@ -107,23 +88,21 @@ describe("CommandQueue", () => { 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, - }; - }); + const consoleErrorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => { + return { + ok: true, + data: undefined, + }; + }); // Force checkSetup to succeed vi.mocked(checkSetup).mockReturnValue({ ok: true, data: undefined }); // Mock command that fails const failingCmd = vi.fn(async () => { - await new Promise((resolve) => { - setTimeout(() => { - resolve("some error"); - }, 10); - }); + await delayedResult("some error", 10); throw new Error("some error"); }); @@ -131,24 +110,19 @@ describe("CommandQueue", () => { queue.add(failingCmd, true); await queue.wait(); - expect(consoleErrorSpy).toHaveBeenCalledWith("🧱 Formbricks - Global error: ", expect.any(Error)); + 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); - }); + return delayedResult({ ok: true, data: undefined }, 10); }); const cmd2 = vi.fn(async (): Promise> => { - return new Promise((resolve) => { - setTimeout(() => { - resolve({ ok: true, data: undefined }); - }, 10); - }); + return delayedResult({ ok: true, data: undefined }, 10); }); vi.mocked(checkSetup).mockReturnValue({ ok: true, data: undefined }); diff --git a/packages/react-native/src/lib/common/tests/config.test.ts b/packages/react-native/src/lib/common/tests/config.test.ts index db63fd5..04f13dc 100644 --- a/packages/react-native/src/lib/common/tests/config.test.ts +++ b/packages/react-native/src/lib/common/tests/config.test.ts @@ -15,13 +15,13 @@ describe("RNConfig", () => { vi.clearAllMocks(); // get the config instance - configInstance = RNConfig.getInstance(); + configInstance = await RNConfig.getInstance(); // reset the config await configInstance.resetConfig(); // get the config instance again - configInstance = RNConfig.getInstance(); + configInstance = await RNConfig.getInstance(); }); afterEach(() => { @@ -29,19 +29,23 @@ describe("RNConfig", () => { vi.restoreAllMocks(); }); - test("getInstance() returns a singleton", () => { - const secondInstance = RNConfig.getInstance(); + test("getInstance() returns a singleton", async () => { + const secondInstance = await 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?"); + 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)); + vi.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce( + JSON.stringify(mockConfig) + ); const result = await configInstance.loadFromStorage(); expect(result.ok).toBe(true); @@ -60,7 +64,9 @@ describe("RNConfig", () => { }, }; - vi.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce(JSON.stringify(expiredConfig)); + vi.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce( + JSON.stringify(expiredConfig) + ); const result = await configInstance.loadFromStorage(); expect(result.ok).toBe(false); @@ -76,20 +82,30 @@ describe("RNConfig", () => { 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"); + 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)); + 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"]; + const newStatus = { + value: "error", + expiresAt: "2100-01-01T00:00:00Z", + } as unknown as TConfig["status"]; - configInstance.update({ ...mockConfig, status: newStatus } as unknown as TConfigUpdateInput); + configInstance.update({ + ...mockConfig, + status: newStatus, + } as unknown as TConfigUpdateInput); // The update call should eventually call setItem on AsyncStorage expect(AsyncStorage.setItem).toHaveBeenCalled(); @@ -100,11 +116,15 @@ describe("RNConfig", () => { }); test("saveToStorage() is invoked internally on update()", async () => { - vi.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce(JSON.stringify(mockConfig)); + vi.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce( + JSON.stringify(mockConfig) + ); await new Promise(setImmediate); - configInstance.update({ status: { value: "success", expiresAt: null } } as unknown as TConfigUpdateInput); + 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 @@ -112,7 +132,9 @@ describe("RNConfig", () => { }); test("resetConfig() clears config and AsyncStorage", async () => { - vi.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce(JSON.stringify(mockConfig)); + vi.spyOn(AsyncStorage, "getItem").mockResolvedValueOnce( + JSON.stringify(mockConfig) + ); await new Promise(setImmediate); // Now reset diff --git a/packages/react-native/src/lib/common/tests/setup.test.ts b/packages/react-native/src/lib/common/tests/setup.test.ts index 63759d6..3aa887c 100644 --- a/packages/react-native/src/lib/common/tests/setup.test.ts +++ b/packages/react-native/src/lib/common/tests/setup.test.ts @@ -1,5 +1,14 @@ import AsyncStorage from "@react-native-async-storage/async-storage"; -import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +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, @@ -7,7 +16,13 @@ import { removeAllEventListeners, } from "@/lib/common/event-listeners"; import { Logger } from "@/lib/common/logger"; -import { checkSetup, handleErrorOnFirstSetup, setIsSetup, setup, tearDown } from "@/lib/common/setup"; +import { + checkSetup, + handleErrorOnFirstSetup, + setIsSetup, + setup, + tearDown, +} from "@/lib/common/setup"; import { filterSurveys, isNowExpired } from "@/lib/common/utils"; import { fetchEnvironmentState } from "@/lib/environment/state"; import { DEFAULT_USER_STATE_NO_USER_ID } from "@/lib/user/state"; @@ -71,7 +86,7 @@ vi.mock("@/lib/user/update", () => ({ })); describe("setup.ts", () => { - let getInstanceConfigMock: MockInstance<() => RNConfig>; + let getInstanceConfigMock: MockInstance<() => Promise>; let getInstanceLoggerMock: MockInstance<() => Logger>; const mockLogger = { @@ -85,7 +100,9 @@ describe("setup.ts", () => { setIsSetup(false); getInstanceConfigMock = vi.spyOn(RNConfig, "getInstance"); - getInstanceLoggerMock = vi.spyOn(Logger, "getInstance").mockReturnValue(mockLogger as unknown as Logger); + getInstanceLoggerMock = vi + .spyOn(Logger, "getInstance") + .mockReturnValue(mockLogger as unknown as Logger); }); afterEach(() => { @@ -96,13 +113,21 @@ describe("setup.ts", () => { test("returns ok if already setup", async () => { getInstanceLoggerMock.mockReturnValue(mockLogger as unknown as Logger); setIsSetup(true); - const result = await setup({ environmentId: "env_id", appUrl: "https://my.url" }); + const result = await setup({ + environmentId: "env_id", + appUrl: "https://my.url", + }); expect(result.ok).toBe(true); - expect(mockLogger.debug).toHaveBeenCalledWith("Already set up, skipping setup."); + expect(mockLogger.debug).toHaveBeenCalledWith( + "Already set up, skipping setup." + ); }); test("fails if no environmentId is provided", async () => { - const result = await setup({ environmentId: "", appUrl: "https://my.url" }); + const result = await setup({ + environmentId: "", + appUrl: "https://my.url", + }); expect(result.ok).toBe(false); if (!result.ok) { expect(result.error.code).toBe("missing_field"); @@ -128,14 +153,23 @@ describe("setup.ts", () => { }), }; - getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig); + getInstanceConfigMock.mockReturnValue( + mockConfig as unknown as Promise + ); (isNowExpired as unknown as Mock).mockReturnValue(true); - const result = await setup({ environmentId: "env_123", appUrl: "https://my.url" }); + const result = await setup({ + 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 setup"); + expect(mockLogger.debug).toHaveBeenCalledWith( + "Formbricks was set to an error state." + ); + expect(mockLogger.debug).toHaveBeenCalledWith( + "Error state is not expired, skipping setup" + ); }); test("proceeds if error state is expired", async () => { @@ -149,12 +183,21 @@ describe("setup.ts", () => { }), }; - getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig); + getInstanceConfigMock.mockReturnValue( + mockConfig as unknown as Promise + ); - const result = await setup({ environmentId: "env_123", appUrl: "https://my.url" }); + const result = await setup({ + 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 setup."); + expect(mockLogger.debug).toHaveBeenCalledWith( + "Formbricks was set to an error state." + ); + expect(mockLogger.debug).toHaveBeenCalledWith( + "Error state is expired. Continue with setup." + ); }); test("uses existing config if environmentId/appUrl match, checks for expiration sync", async () => { @@ -172,14 +215,19 @@ describe("setup.ts", () => { update: vi.fn(), }; - getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig); + getInstanceConfigMock.mockReturnValue( + mockConfig as unknown as Promise + ); (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) }, + data: { + data: { surveys: [] }, + expiresAt: new Date(Date.now() + 60_000), + }, }); // Mock sendUpdatesToBackend success @@ -193,9 +241,15 @@ describe("setup.ts", () => { }, }); - (filterSurveys as unknown as Mock).mockReturnValueOnce([{ name: "S1" }, { name: "S2" }]); + (filterSurveys as unknown as Mock).mockReturnValueOnce([ + { name: "S1" }, + { name: "S2" }, + ]); - const result = await setup({ environmentId: "env_123", appUrl: "https://my.url" }); + const result = await setup({ + environmentId: "env_123", + appUrl: "https://my.url", + }); expect(result.ok).toBe(true); // environmentState was fetched @@ -225,7 +279,9 @@ describe("setup.ts", () => { update: vi.fn(), }; - getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig); + getInstanceConfigMock.mockReturnValue( + mockConfig as unknown as Promise + ); (fetchEnvironmentState as unknown as Mock).mockResolvedValueOnce({ ok: true, @@ -237,11 +293,18 @@ describe("setup.ts", () => { }, }); - (filterSurveys as unknown as Mock).mockReturnValueOnce([{ name: "SurveyA" }]); + (filterSurveys as unknown as Mock).mockReturnValueOnce([ + { name: "SurveyA" }, + ]); - const result = await setup({ environmentId: "envX", appUrl: "https://urlX" }); + const result = await setup({ + environmentId: "envX", + appUrl: "https://urlX", + }); expect(result.ok).toBe(true); - expect(mockLogger.debug).toHaveBeenCalledWith("No existing configuration found."); + expect(mockLogger.debug).toHaveBeenCalledWith( + "No existing configuration found." + ); expect(mockLogger.debug).toHaveBeenCalledWith( "No valid configuration found. Resetting config and creating new one." ); @@ -269,16 +332,18 @@ describe("setup.ts", () => { resetConfig: vi.fn(), }; - getInstanceConfigMock.mockReturnValueOnce(mockConfig as unknown as RNConfig); + getInstanceConfigMock.mockReturnValueOnce( + mockConfig as unknown as Promise + ); (fetchEnvironmentState as unknown as Mock).mockResolvedValueOnce({ ok: false, error: { code: "forbidden", responseMessage: "No access" }, }); - await expect(setup({ environmentId: "envX", appUrl: "https://urlX" })).rejects.toThrow( - "Could not set up formbricks" - ); + await expect( + setup({ environmentId: "envX", appUrl: "https://urlX" }) + ).rejects.toThrow("Could not set up formbricks"); }); test("adds event listeners and sets isSetup", async () => { @@ -293,9 +358,14 @@ describe("setup.ts", () => { update: vi.fn(), }; - getInstanceConfigMock.mockReturnValueOnce(mockConfig as unknown as RNConfig); + getInstanceConfigMock.mockReturnValueOnce( + mockConfig as unknown as Promise + ); - const result = await setup({ environmentId: "env_abc", appUrl: "https://test.app" }); + const result = await setup({ + environmentId: "env_abc", + appUrl: "https://test.app", + }); expect(result.ok).toBe(true); expect(addEventListeners).toHaveBeenCalled(); expect(addCleanupEventListeners).toHaveBeenCalled(); @@ -327,7 +397,9 @@ describe("setup.ts", () => { update: vi.fn(), }; - getInstanceConfigMock.mockReturnValueOnce(mockConfig as unknown as RNConfig); + getInstanceConfigMock.mockReturnValueOnce( + mockConfig as unknown as Promise + ); await tearDown(); diff --git a/packages/react-native/src/lib/common/tests/utils.test.ts b/packages/react-native/src/lib/common/tests/utils.test.ts index a93a0e3..d080dce 100644 --- a/packages/react-native/src/lib/common/tests/utils.test.ts +++ b/packages/react-native/src/lib/common/tests/utils.test.ts @@ -5,6 +5,7 @@ import { mockSurveyId, } from "@/lib/common/tests/__mocks__/config.mock"; import { + delayedResult, diffInDays, filterSurveys, getDefaultLanguageCode, @@ -54,9 +55,7 @@ describe("utils.ts", () => { describe("wrapThrowsAsync()", () => { test("returns ok on success", async () => { const fn = vi.fn(async (x: number) => { - await new Promise((r) => { - setTimeout(r, 10); - }); + await delayedResult(null, 10); return x * 2; }); @@ -71,9 +70,7 @@ describe("utils.ts", () => { test("returns err on error", async () => { const fn = vi.fn(async () => { - await new Promise((r) => { - setTimeout(r, 10); - }); + await delayedResult(null, 10); throw new Error("Something broke"); }); const wrapped = wrapThrowsAsync(fn); @@ -359,7 +356,7 @@ describe("utils.ts", () => { const survey = { languages: [{ language: { code: "en" }, default: true, enabled: true }], } as unknown as TSurvey; - const code = getLanguageCode(survey, undefined); + const code = getLanguageCode(survey); expect(code).toBe("default"); }); @@ -422,4 +419,17 @@ describe("utils.ts", () => { mockedRandom.mockRestore(); }); }); + + // --------------------------------------------------------------------------------- + // delayedResult + // --------------------------------------------------------------------------------- + describe("delayedResult()", () => { + test("returns the value after the delay", async () => { + const result = await delayedResult(10, 10); + expect(result).toBe(10); + + const result2 = await delayedResult(20, 20); + expect(result2).toBe(20); + }); + }); }); diff --git a/packages/react-native/src/lib/common/utils.ts b/packages/react-native/src/lib/common/utils.ts index 960e319..dd9d40e 100644 --- a/packages/react-native/src/lib/common/utils.ts +++ b/packages/react-native/src/lib/common/utils.ts @@ -90,6 +90,7 @@ export const filterSurveys = ( // 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. + // NOSONAR // TODO: Remove the surveyId from the displays array if (survey.recontactDays !== null) { return ( @@ -187,3 +188,7 @@ export const shouldDisplayBasedOnPercentage = ( export const isNowExpired = (expirationDate: Date): boolean => { return new Date() >= expirationDate; }; + +export const delayedResult = async (value: T, ms: number): Promise => { + return new Promise((resolve) => setTimeout(() => resolve(value), ms)); +}; diff --git a/packages/react-native/src/lib/environment/state.ts b/packages/react-native/src/lib/environment/state.ts index 96f65d1..434508f 100644 --- a/packages/react-native/src/lib/environment/state.ts +++ b/packages/react-native/src/lib/environment/state.ts @@ -51,64 +51,65 @@ export const fetchEnvironmentState = async ({ /** * 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(); +export const addEnvironmentStateExpiryCheckListener = + async (): Promise => { + const appConfig = await RNConfig.getInstance(); + const logger = Logger.getInstance(); - const updateInterval = 1000 * 60; // every minute + const updateInterval = 1000 * 60; // every minute - if (environmentStateSyncIntervalId === null) { - const intervalHandler = async (): Promise => { - const expiresAt = appConfig.get().environment.expiresAt; + 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; - } + 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."); + 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, - }); + 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); + 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({ - ...appConfig.get(), - environment: state, - filteredSurveys, + ...existingConfig, + environment: { + ...existingConfig.environment, + expiresAt: new Date(new Date().getTime() + 1000 * 60 * 30), // 30 minutes + }, }); - } 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; - } -}; + environmentStateSyncIntervalId = setInterval( + () => void intervalHandler(), + updateInterval + ) as unknown as number; + } + }; export const clearEnvironmentStateExpiryCheckListener = (): void => { if (environmentStateSyncIntervalId) { diff --git a/packages/react-native/src/lib/environment/tests/state.test.ts b/packages/react-native/src/lib/environment/tests/state.test.ts index 5d2937e..bcde752 100644 --- a/packages/react-native/src/lib/environment/tests/state.test.ts +++ b/packages/react-native/src/lib/environment/tests/state.test.ts @@ -9,7 +9,16 @@ import { fetchEnvironmentState, } from "@/lib/environment/state"; import type { TEnvironmentState } from "@/types/config"; -import { type Mock, type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + type Mock, + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + test, + vi, +} from "vitest"; // Mock the FormbricksAPI so we can control environment.getState vi.mock("@/lib/common/api", () => ({ @@ -65,7 +74,10 @@ describe("environment/state.ts", () => { return { getEnvironmentState: vi.fn().mockResolvedValue({ ok: true, - data: { data: { foo: "bar" }, expiresAt: new Date(Date.now() + 1000 * 60 * 30) }, + data: { + data: { foo: "bar" }, + expiresAt: new Date(Date.now() + 1000 * 60 * 30), + }, }), }; }); @@ -85,7 +97,11 @@ describe("environment/state.ts", () => { }); test("returns err(...) if environment.getState is not ok", async () => { - const mockError = { code: "forbidden", status: 403, message: "Access denied" }; + const mockError = { + code: "forbidden", + status: 403, + message: "Access denied", + }; (ApiClient as unknown as Mock).mockImplementationOnce(() => { return { @@ -129,13 +145,15 @@ describe("environment/state.ts", () => { if (!result.ok) { expect(result.error.code).toBe(mockNetworkError.code); expect(result.error.message).toBe(mockNetworkError.message); - expect(result.error.responseMessage).toBe(mockNetworkError.responseMessage); + expect(result.error.responseMessage).toBe( + mockNetworkError.responseMessage + ); } }); }); describe("addEnvironmentStateExpiryCheckListener()", () => { - let mockRNConfig: MockInstance<() => RNConfig>; + let mockRNConfig: MockInstance<() => Promise>; let mockLoggerInstance: MockInstance<() => Logger>; const mockLogger = { @@ -159,7 +177,7 @@ describe("environment/state.ts", () => { }), }; - mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig); + mockRNConfig.mockReturnValue(mockConfig as unknown as Promise); mockLoggerInstance = vi.spyOn(Logger, "getInstance"); mockLoggerInstance.mockReturnValue(mockLogger as unknown as Logger); @@ -188,7 +206,7 @@ describe("environment/state.ts", () => { }, }; - mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig); + mockRNConfig.mockReturnValue(mockConfig as unknown as Promise); (ApiClient as Mock).mockImplementation(() => ({ getEnvironmentState: vi.fn().mockResolvedValue({ @@ -221,11 +239,13 @@ describe("environment/state.ts", () => { update: vi.fn(), }; - mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig); + mockRNConfig.mockReturnValue(mockConfig as unknown as Promise); // Mock API to throw an error (ApiClient as Mock).mockImplementation(() => ({ - getEnvironmentState: vi.fn().mockRejectedValue(new Error("Network error")), + getEnvironmentState: vi + .fn() + .mockRejectedValue(new Error("Network error")), })); addEnvironmentStateExpiryCheckListener(); @@ -250,7 +270,7 @@ describe("environment/state.ts", () => { update: vi.fn(), }; - mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig); + mockRNConfig.mockReturnValue(mockConfig as unknown as Promise); const apiMock = vi.fn().mockImplementation(() => ({ getEnvironmentState: vi.fn(), @@ -266,10 +286,10 @@ describe("environment/state.ts", () => { expect(mockConfig.update).not.toHaveBeenCalled(); }); - test("clears interval when clearEnvironmentStateExpiryCheckListener is called", () => { + test("clears interval when clearEnvironmentStateExpiryCheckListener is called", async () => { const clearIntervalSpy = vi.spyOn(global, "clearInterval"); - addEnvironmentStateExpiryCheckListener(); + await addEnvironmentStateExpiryCheckListener(); clearEnvironmentStateExpiryCheckListener(); expect(clearIntervalSpy).toHaveBeenCalled(); diff --git a/packages/react-native/src/lib/survey/action.ts b/packages/react-native/src/lib/survey/action.ts index 9973512..95a2ca3 100644 --- a/packages/react-native/src/lib/survey/action.ts +++ b/packages/react-native/src/lib/survey/action.ts @@ -42,12 +42,12 @@ export const triggerSurvey = (survey: TSurvey): void => { * @param alias - Optional alias for the action name * @returns Result indicating success or network error */ -export const trackAction = ( +export const trackAction = async ( name: string, alias?: string -): Result => { +): Promise> => { const logger = Logger.getInstance(); - const appConfig = RNConfig.getInstance(); + const appConfig = await RNConfig.getInstance(); const aliasName = alias ?? name; @@ -84,7 +84,7 @@ export const track = async ( | Result > => { try { - const appConfig = RNConfig.getInstance(); + const appConfig = await RNConfig.getInstance(); const netInfo = await fetch(); diff --git a/packages/react-native/src/lib/survey/store.ts b/packages/react-native/src/lib/survey/store.ts index 913c341..cb76534 100644 --- a/packages/react-native/src/lib/survey/store.ts +++ b/packages/react-native/src/lib/survey/store.ts @@ -5,12 +5,10 @@ type Listener = (state: TSurvey | null, prevSurvey: TSurvey | null) => void; export class SurveyStore { private static instance: SurveyStore | undefined; private survey: TSurvey | null = null; - private listeners = new Set(); + private readonly listeners = new Set(); static getInstance(): SurveyStore { - if (!SurveyStore.instance) { - SurveyStore.instance = new SurveyStore(); - } + SurveyStore.instance ??= new SurveyStore(); return SurveyStore.instance; } diff --git a/packages/react-native/src/lib/survey/tests/action.test.ts b/packages/react-native/src/lib/survey/tests/action.test.ts index bdf3419..1fbe023 100644 --- a/packages/react-native/src/lib/survey/tests/action.test.ts +++ b/packages/react-native/src/lib/survey/tests/action.test.ts @@ -74,7 +74,9 @@ describe("survey/action.ts", () => { const getInstanceLogger = vi.spyOn(Logger, "getInstance"); // Mock instances - getInstanceRn.mockReturnValue(mockAppConfig as unknown as RNConfig); + getInstanceRn.mockReturnValue( + mockAppConfig as unknown as Promise + ); getInstanceSurveyStore.mockReturnValue( mockSurveyStore as unknown as SurveyStore ); @@ -120,21 +122,21 @@ describe("survey/action.ts", () => { }); }); - test("triggers survey associated with action name", () => { + test("triggers survey associated with action name", async () => { (shouldDisplayBasedOnPercentage as unknown as Mock).mockReturnValue(true); - trackAction("testAction"); + await 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", () => { + test("does not trigger survey if no active surveys are found", async () => { mockAppConfig.get.mockReturnValue({ filteredSurveys: [], }); - trackAction("testAction"); + await trackAction("testAction"); // Ensure no surveys are triggered expect(mockSurveyStore.setSurvey).not.toHaveBeenCalled(); @@ -143,8 +145,8 @@ describe("survey/action.ts", () => { ); }); - test("logs tracked action name", () => { - trackAction("testAction"); + test("logs tracked action name", async () => { + await trackAction("testAction"); expect(mockLogger.debug).toHaveBeenCalledWith( 'Formbricks: Action "testAction" tracked' diff --git a/packages/react-native/src/lib/user/attribute.ts b/packages/react-native/src/lib/user/attribute.ts index cdb3c67..59b3950 100644 --- a/packages/react-native/src/lib/user/attribute.ts +++ b/packages/react-native/src/lib/user/attribute.ts @@ -6,7 +6,7 @@ export const setAttributes = async ( // eslint-disable-next-line @typescript-eslint/require-await -- we want to use promises here ): Promise> => { const updateQueue = UpdateQueue.getInstance(); - updateQueue.updateAttributes(attributes); + await 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 index 388d3e8..f19649e 100644 --- a/packages/react-native/src/lib/user/state.ts +++ b/packages/react-native/src/lib/user/state.ts @@ -18,8 +18,8 @@ export const DEFAULT_USER_STATE_NO_USER_ID: TUserState = { /** * Add a listener to check if the user state has expired with a certain interval */ -export const addUserStateExpiryCheckListener = (): void => { - const config = RNConfig.getInstance(); +export const addUserStateExpiryCheckListener = async (): Promise => { + const config = await RNConfig.getInstance(); const updateInterval = 1000 * 60; // every 60 seconds if (userStateSyncIntervalId === null) { @@ -40,7 +40,10 @@ export const addUserStateExpiryCheckListener = (): void => { }); }; - userStateSyncIntervalId = setInterval(intervalHandler, updateInterval) as unknown as number; + userStateSyncIntervalId = setInterval( + intervalHandler, + updateInterval + ) as unknown as number; } }; diff --git a/packages/react-native/src/lib/user/tests/attribute.test.ts b/packages/react-native/src/lib/user/tests/attribute.test.ts index b827c71..5dc600f 100644 --- a/packages/react-native/src/lib/user/tests/attribute.test.ts +++ b/packages/react-native/src/lib/user/tests/attribute.test.ts @@ -1,6 +1,7 @@ import { beforeEach, describe, expect, test, vi } from "vitest"; import { setAttributes } from "@/lib/user/attribute"; import { UpdateQueue } from "@/lib/user/update-queue"; +import { delayedResult } from "@/lib/common/utils"; export const mockAttributes = { name: "John Doe", @@ -27,7 +28,9 @@ describe("User Attributes", () => { vi.clearAllMocks(); const getInstanceUpdateQueue = vi.spyOn(UpdateQueue, "getInstance"); - getInstanceUpdateQueue.mockReturnValue(mockUpdateQueue as unknown as UpdateQueue); + getInstanceUpdateQueue.mockReturnValue( + mockUpdateQueue as unknown as UpdateQueue + ); }); describe("setAttributes", () => { @@ -35,7 +38,9 @@ describe("User Attributes", () => { const result = await setAttributes(mockAttributes); // Verify UpdateQueue methods were called correctly - expect(mockUpdateQueue.updateAttributes).toHaveBeenCalledWith(mockAttributes); + expect(mockUpdateQueue.updateAttributes).toHaveBeenCalledWith( + mockAttributes + ); expect(mockUpdateQueue.processUpdates).toHaveBeenCalled(); // Verify result is ok @@ -50,8 +55,14 @@ describe("User Attributes", () => { await setAttributes(secondAttributes); expect(mockUpdateQueue.updateAttributes).toHaveBeenCalledTimes(2); - expect(mockUpdateQueue.updateAttributes).toHaveBeenNthCalledWith(1, firstAttributes); - expect(mockUpdateQueue.updateAttributes).toHaveBeenNthCalledWith(2, secondAttributes); + expect(mockUpdateQueue.updateAttributes).toHaveBeenNthCalledWith( + 1, + firstAttributes + ); + expect(mockUpdateQueue.updateAttributes).toHaveBeenNthCalledWith( + 2, + secondAttributes + ); expect(mockUpdateQueue.processUpdates).toHaveBeenCalledTimes(2); }); @@ -59,11 +70,8 @@ describe("User Attributes", () => { const attributes = { name: mockAttributes.name }; // Mock processUpdates to be async - mockUpdateQueue.processUpdates.mockImplementation( - () => - new Promise((resolve) => { - setTimeout(resolve, 100); - }) + mockUpdateQueue.processUpdates.mockImplementation(() => + delayedResult(undefined, 100) ); const result = await setAttributes(attributes); diff --git a/packages/react-native/src/lib/user/tests/state.test.ts b/packages/react-native/src/lib/user/tests/state.test.ts index decc0d3..d9a240f 100644 --- a/packages/react-native/src/lib/user/tests/state.test.ts +++ b/packages/react-native/src/lib/user/tests/state.test.ts @@ -1,6 +1,17 @@ -import { type MockInstance, afterEach, beforeEach, describe, expect, test, vi } from "vitest"; +import { + type MockInstance, + afterEach, + beforeEach, + describe, + expect, + test, + vi, +} from "vitest"; import { RNConfig } from "@/lib/common/config"; -import { addUserStateExpiryCheckListener, clearUserStateExpiryCheckListener } from "@/lib/user/state"; +import { + addUserStateExpiryCheckListener, + clearUserStateExpiryCheckListener, +} from "@/lib/user/state"; const mockUserId = "user_123"; @@ -14,7 +25,7 @@ vi.mock("@/lib/common/config", () => ({ })); describe("User State Expiry Check Listener", () => { - let mockRNConfig: MockInstance<() => RNConfig>; + let mockRNConfig: MockInstance<() => Promise>; beforeEach(() => { vi.clearAllMocks(); @@ -27,7 +38,7 @@ describe("User State Expiry Check Listener", () => { clearUserStateExpiryCheckListener(); // Ensure cleanup after each test }); - test("should set an interval if not already set and update user state expiry when userId exists", () => { + test("should set an interval if not already set and update user state expiry when userId exists", async () => { const mockConfig = { get: vi.fn().mockReturnValue({ user: { data: { userId: mockUserId } }, @@ -35,9 +46,9 @@ describe("User State Expiry Check Listener", () => { update: vi.fn(), }; - mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig); + mockRNConfig.mockReturnValue(mockConfig as unknown as Promise); - addUserStateExpiryCheckListener(); + await addUserStateExpiryCheckListener(); // Fast-forward time by 1 minute (60,000 ms) vi.advanceTimersByTime(60_000); @@ -59,7 +70,7 @@ describe("User State Expiry Check Listener", () => { update: vi.fn(), }; - mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig); + mockRNConfig.mockReturnValue(mockConfig as unknown as Promise); addUserStateExpiryCheckListener(); vi.advanceTimersByTime(60_000); // Fast-forward 1 minute @@ -67,7 +78,7 @@ describe("User State Expiry Check Listener", () => { expect(mockConfig.update).not.toHaveBeenCalled(); // Ensures no update when no userId }); - test("should not set multiple intervals if already set", () => { + test("should not set multiple intervals if already set", async () => { const mockConfig = { get: vi.fn().mockReturnValue({ user: { data: { userId: mockUserId } }, @@ -75,10 +86,10 @@ describe("User State Expiry Check Listener", () => { update: vi.fn(), }; - mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig); + mockRNConfig.mockReturnValue(mockConfig as unknown as Promise); - addUserStateExpiryCheckListener(); - addUserStateExpiryCheckListener(); // Call again to check if it prevents multiple intervals + await addUserStateExpiryCheckListener(); + await addUserStateExpiryCheckListener(); // Call again to check if it prevents multiple intervals vi.advanceTimersByTime(60_000); // Fast-forward 1 minute @@ -91,7 +102,7 @@ describe("User State Expiry Check Listener", () => { update: vi.fn(), }; - mockRNConfig.mockReturnValue(mockConfig as unknown as RNConfig); + mockRNConfig.mockReturnValue(mockConfig as unknown as Promise); addUserStateExpiryCheckListener(); clearUserStateExpiryCheckListener(); 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 index 8dfe674..44018f3 100644 --- a/packages/react-native/src/lib/user/tests/update-queue.test.ts +++ b/packages/react-native/src/lib/user/tests/update-queue.test.ts @@ -1,5 +1,9 @@ import { type Mock, beforeEach, describe, expect, test, vi } from "vitest"; -import { mockAttributes, mockUserId1, mockUserId2 } from "@/lib/user/tests/__mocks__/update-queue.mock"; +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"; @@ -72,9 +76,9 @@ describe("UpdateQueue", () => { }); }); - test("updateAttributes sets attributes correctly when updates is null", () => { + test("updateAttributes sets attributes correctly when updates is null", async () => { const attributes = mockAttributes; - updateQueue.updateAttributes(attributes); + await updateQueue.updateAttributes(attributes); expect(updateQueue.getUpdates()).toEqual({ userId: "mock-user-id", // from mocked config @@ -82,9 +86,9 @@ describe("UpdateQueue", () => { }); }); - test("updateAttributes merges with existing attributes", () => { - updateQueue.updateAttributes({ name: mockAttributes.name }); - updateQueue.updateAttributes({ email: mockAttributes.email }); + test("updateAttributes merges with existing attributes", async () => { + await updateQueue.updateAttributes({ name: mockAttributes.name }); + await updateQueue.updateAttributes({ email: mockAttributes.email }); expect(updateQueue.getUpdates()).toEqual({ userId: "mock-user-id", @@ -95,8 +99,8 @@ describe("UpdateQueue", () => { }); }); - test("clearUpdates resets updates to null", () => { - updateQueue.updateAttributes({ name: mockAttributes.name }); + test("clearUpdates resets updates to null", async () => { + await updateQueue.updateAttributes({ name: mockAttributes.name }); updateQueue.clearUpdates(); expect(updateQueue.getUpdates()).toBeNull(); }); @@ -105,8 +109,8 @@ describe("UpdateQueue", () => { expect(updateQueue.isEmpty()).toBe(true); }); - test("isEmpty returns false when updates exist", () => { - updateQueue.updateAttributes({ name: mockAttributes.name }); + test("isEmpty returns false when updates exist", async () => { + await updateQueue.updateAttributes({ name: mockAttributes.name }); expect(updateQueue.isEmpty()).toBe(false); }); @@ -117,8 +121,8 @@ describe("UpdateQueue", () => { ok: true, }); - updateQueue.updateAttributes({ name: mockAttributes.name }); - updateQueue.updateAttributes({ email: mockAttributes.email }); + await updateQueue.updateAttributes({ name: mockAttributes.name }); + await updateQueue.updateAttributes({ email: mockAttributes.email }); // Wait for debounce timeout await new Promise((resolve) => { @@ -140,7 +144,7 @@ describe("UpdateQueue", () => { update: configUpdateMock, })); - updateQueue.updateAttributes({ language: "en" }); + await updateQueue.updateAttributes({ language: "en" }); await updateQueue.processUpdates(); expect(configUpdateMock).toHaveBeenCalled(); @@ -153,7 +157,7 @@ describe("UpdateQueue", () => { })), })); - updateQueue.updateAttributes({ name: mockAttributes.name }); + await 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/user.test.ts b/packages/react-native/src/lib/user/tests/user.test.ts index 27ccdf2..6470275 100644 --- a/packages/react-native/src/lib/user/tests/user.test.ts +++ b/packages/react-native/src/lib/user/tests/user.test.ts @@ -1,4 +1,12 @@ -import { type Mock, type MockInstance, beforeEach, describe, expect, test, vi } from "vitest"; +import { + type Mock, + type MockInstance, + beforeEach, + describe, + expect, + test, + vi, +} from "vitest"; import { RNConfig } from "@/lib/common/config"; import { Logger } from "@/lib/common/logger"; import { setup, tearDown } from "@/lib/common/setup"; @@ -42,7 +50,7 @@ describe("user.ts", () => { const mockEnvironmentId = "env-123"; const mockAppUrl = "https://test.com"; - let getInstanceConfigMock: MockInstance<() => RNConfig>; + let getInstanceConfigMock: MockInstance<() => Promise>; let getInstanceLoggerMock: MockInstance<() => Logger>; let getInstanceUpdateQueueMock: MockInstance<() => UpdateQueue>; @@ -70,7 +78,9 @@ describe("user.ts", () => { error: vi.fn(), }; - getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig); + getInstanceConfigMock.mockReturnValue( + mockConfig as unknown as Promise + ); getInstanceLoggerMock.mockReturnValue(mockLogger as unknown as Logger); const result = await setUserId(mockUserId); @@ -104,9 +114,13 @@ describe("user.ts", () => { processUpdates: vi.fn(), }; - getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig); + getInstanceConfigMock.mockReturnValue( + mockConfig as unknown as Promise + ); getInstanceLoggerMock.mockReturnValue(mockLogger as unknown as Logger); - getInstanceUpdateQueueMock.mockReturnValue(mockUpdateQueue as unknown as UpdateQueue); + getInstanceUpdateQueueMock.mockReturnValue( + mockUpdateQueue as unknown as UpdateQueue + ); const result = await setUserId(mockUserId); expect(result.ok).toBe(true); @@ -125,41 +139,24 @@ describe("user.ts", () => { }), }; - getInstanceConfigMock.mockReturnValue(mockConfig as unknown as RNConfig); + getInstanceConfigMock.mockReturnValue( + mockConfig as unknown as Promise + ); (setup as Mock).mockResolvedValue(undefined); const result = await logout(); expect(tearDown).toHaveBeenCalled(); - expect(setup).toHaveBeenCalledWith({ - environmentId: mockEnvironmentId, - appUrl: mockAppUrl, - }); expect(result.ok).toBe(true); }); test("returns error if setup 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" }; - (setup as Mock).mockRejectedValue(mockError); + const mockError = new Error("Failed to logout"); + getInstanceConfigMock.mockRejectedValue(mockError); const result = await logout(); - expect(tearDown).toHaveBeenCalled(); - expect(setup).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 index 4904264..c020533 100644 --- a/packages/react-native/src/lib/user/update-queue.ts +++ b/packages/react-native/src/lib/user/update-queue.ts @@ -15,9 +15,7 @@ export class UpdateQueue { private constructor() {} public static getInstance(): UpdateQueue { - if (!UpdateQueue.instance) { - UpdateQueue.instance = new UpdateQueue(); - } + UpdateQueue.instance ??= new UpdateQueue(); return UpdateQueue.instance; } @@ -36,8 +34,8 @@ export class UpdateQueue { } } - public updateAttributes(attributes: TAttributes): void { - const config = RNConfig.getInstance(); + public async updateAttributes(attributes: TAttributes): Promise { + const config = await RNConfig.getInstance(); // Get userId from updates first, then fallback to config const userId = this.updates?.userId ?? config.get().user.data.userId ?? ""; @@ -80,7 +78,7 @@ export class UpdateQueue { const handler = async (): Promise => { try { let currentUpdates = { ...this.updates }; - const config = RNConfig.getInstance(); + const config = await RNConfig.getInstance(); if (Object.keys(currentUpdates).length > 0) { // Get userId from either updates or config diff --git a/packages/react-native/src/lib/user/update.ts b/packages/react-native/src/lib/user/update.ts index d4afbd9..94810f2 100644 --- a/packages/react-native/src/lib/user/update.ts +++ b/packages/react-native/src/lib/user/update.ts @@ -4,7 +4,13 @@ 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"; +import { + type ApiErrorResponse, + type Result, + err, + ok, + okVoid, +} from "@/types/error"; export const sendUpdatesToBackend = async ({ appUrl, @@ -64,7 +70,7 @@ export const sendUpdates = async ({ }: { updates: TUpdates; }): Promise> => { - const config = RNConfig.getInstance(); + const config = await RNConfig.getInstance(); const logger = Logger.getInstance(); const { appUrl, environmentId } = config.get(); @@ -72,11 +78,18 @@ export const sendUpdates = async ({ const url = `${appUrl}/api/v1/client/${environmentId}/user`; try { - const updatesResponse = await sendUpdatesToBackend({ appUrl, environmentId, updates }); + const updatesResponse = await sendUpdatesToBackend({ + appUrl, + environmentId, + updates, + }); if (updatesResponse.ok) { const userState = updatesResponse.data.state; - const filteredSurveys = filterSurveys(config.get().environment, userState); + 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 diff --git a/packages/react-native/src/lib/user/user.ts b/packages/react-native/src/lib/user/user.ts index 5c44ad7..699d370 100644 --- a/packages/react-native/src/lib/user/user.ts +++ b/packages/react-native/src/lib/user/user.ts @@ -1,12 +1,14 @@ import { RNConfig } from "@/lib/common/config"; import { Logger } from "@/lib/common/logger"; -import { setup, tearDown } from "@/lib/common/setup"; +import { tearDown } from "@/lib/common/setup"; import { UpdateQueue } from "@/lib/user/update-queue"; -import { type ApiErrorResponse, type NetworkError, type Result, err, okVoid } from "@/types/error"; +import { type ApiErrorResponse, 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(); +export const setUserId = async ( + userId: string +): Promise> => { + const appConfig = await RNConfig.getInstance(); const logger = Logger.getInstance(); const updateQueue = UpdateQueue.getInstance(); @@ -31,30 +33,25 @@ export const setUserId = async (userId: string): Promise> => { - const logger = Logger.getInstance(); - const appConfig = RNConfig.getInstance(); - - const { userId } = appConfig.get().user.data; +export const logout = async (): Promise> => { + try { + const logger = Logger.getInstance(); + const appConfig = await RNConfig.getInstance(); - if (!userId) { - logger.debug("No userId is set, please use the setUserId function to set a userId first"); - return okVoid(); - } + const { userId } = appConfig.get().user.data; - logger.debug("Resetting state & getting new state from backend"); - const initParams = { - environmentId: appConfig.get().environmentId, - appUrl: appConfig.get().appUrl, - }; + if (!userId) { + logger.debug( + "No userId is set, please use the setUserId function to set a userId first" + ); + return okVoid(); + } - // logout the user, remove user state and setup formbricks again - await tearDown(); + logger.debug("Resetting user state"); - try { - await setup(initParams); + await tearDown(); return okVoid(); - } catch (e) { - return err(e as NetworkError); + } catch { + return { ok: false, error: new Error("Failed to logout") }; } }; diff --git a/packages/react-native/src/types/config.ts b/packages/react-native/src/types/config.ts index 862ef5b..530a9d8 100644 --- a/packages/react-native/src/types/config.ts +++ b/packages/react-native/src/types/config.ts @@ -73,7 +73,7 @@ export interface TConfigInput { export interface TStylingColor { light: string; - dark?: string | null | undefined; + dark?: string | null; } export interface TBaseStyling { diff --git a/packages/react-native/src/types/storage.ts b/packages/react-native/src/types/storage.ts index a6099a1..ab9095d 100644 --- a/packages/react-native/src/types/storage.ts +++ b/packages/react-native/src/types/storage.ts @@ -1,8 +1,8 @@ import { z } from "zod"; export interface TUploadFileConfig { - allowedFileExtensions?: string[] | undefined; - surveyId?: string | undefined; + allowedFileExtensions?: string[]; + surveyId?: string; } export interface TUploadFileResponse { @@ -15,7 +15,7 @@ export interface TUploadFileResponse { uuid: string; } | null; updatedFileName: string; - presignedFields?: Record | undefined; + presignedFields?: Record; }; } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 84b3a0a..915abbe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -11,6 +11,9 @@ importers: prettier: specifier: ^3.5.3 version: 3.6.2 + rimraf: + specifier: 6.0.1 + version: 6.0.1 turbo: specifier: ^2.5.2 version: 2.5.4 @@ -869,6 +872,14 @@ packages: resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} deprecated: Use @eslint/object-schema instead + '@isaacs/balanced-match@4.0.1': + resolution: {integrity: sha512-yzMTt9lEb8Gv7zRioUilSglI0c0smZ9k5D65677DLWLtWJaXIS3CqcGyUFByYKlnUj6TkjLVs54fBl6+TiGQDQ==} + engines: {node: 20 || >=22} + + '@isaacs/brace-expansion@5.0.0': + resolution: {integrity: sha512-ZT55BDLV0yv0RBm2czMiZ+SqCGO7AvmOM3G/w2xhVPH+te0aKgFjmBvGlL1dH+ql2tgGO3MVrbb3jCKyvpgnxA==} + engines: {node: 20 || >=22} + '@isaacs/cliui@8.0.2': resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} engines: {node: '>=12'} @@ -2632,6 +2643,11 @@ packages: resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} hasBin: true + glob@11.0.3: + resolution: {integrity: sha512-2Nim7dha1KVkaiF4q6Dj+ngPPMdfvLJEOpZk/jKiUAkqKebpGAWQXAq9z1xu9HKu5lWfqw/FASuccEjyznjPaA==} + engines: {node: 20 || >=22} + hasBin: true + glob@7.2.3: resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} deprecated: Glob versions prior to v9 are no longer supported @@ -2948,6 +2964,10 @@ packages: jackspeak@3.4.3: resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + jackspeak@4.1.1: + resolution: {integrity: sha512-zptv57P3GpL+O0I7VdMJNBZCu+BPHVQUk55Ft8/QCJjTVxrnJHuVuX/0Bl2A6/+2oyR/ZMEuFKwmzqqZ/U5nPQ==} + engines: {node: 20 || >=22} + jest-environment-node@29.7.0: resolution: {integrity: sha512-DOSwCRqXirTOyheM+4d5YZOrWcdu0LNZ87ewUoywbcb2XR4wKgqiG8vNeYwhjFMbEkfju7wx2GYH0P2gevGvFw==} engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} @@ -3192,6 +3212,10 @@ packages: lru-cache@10.4.3: resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + lru-cache@11.1.0: + resolution: {integrity: sha512-QIXZUBJUx+2zHUdQujWejBkcD9+cs94tLn0+YL8UrCh+D5sCXZ4c7LaEH48pNwRY3MLDgqUFyhlCyjJPf1WP0A==} + engines: {node: 20 || >=22} + lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} @@ -3320,6 +3344,10 @@ packages: resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} engines: {node: '>=4'} + minimatch@10.0.3: + resolution: {integrity: sha512-IPZ167aShDZZUMdRk66cyQAW3qr0WzbHkPdMYa8bzZhlHhO3jALbKdxcaak7W9FfT2rZNpQuUu4Od7ILEpXSaw==} + engines: {node: 20 || >=22} + minimatch@3.0.8: resolution: {integrity: sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==} @@ -3554,6 +3582,10 @@ packages: resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} engines: {node: '>=16 || 14 >=14.18'} + path-scurry@2.0.0: + resolution: {integrity: sha512-ypGJsmGtdXUOeM5u93TyeIEfEhM6s+ljAhrk5vAvSx8uyY/02OvrZnA0YNGUrPXfpJMgI1ODd3nwz8Npx4O4cg==} + engines: {node: 20 || >=22} + path-type@4.0.0: resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} engines: {node: '>=8'} @@ -3835,6 +3867,11 @@ packages: deprecated: Rimraf versions prior to v4 are no longer supported hasBin: true + rimraf@6.0.1: + resolution: {integrity: sha512-9dkvaxAsk/xNXSJzMgFqqMCuFgt2+KsOFek3TMLfo8NCPfWpBmqwyNn5Y+NX56QUYfCtsyhF3ayiboEoUmJk/A==} + engines: {node: 20 || >=22} + hasBin: true + rollup@4.44.1: resolution: {integrity: sha512-x8H8aPvD+xbl0Do8oez5f5o8eMS3trfCghc4HhLAnCkj7Vl0d1JWGs0UF/D886zLW2rOj2QymV/JcSSsw+XDNg==} engines: {node: '>=18.0.0', npm: '>=8.0.0'} @@ -5613,6 +5650,12 @@ snapshots: '@humanwhocodes/object-schema@2.0.3': {} + '@isaacs/balanced-match@4.0.1': {} + + '@isaacs/brace-expansion@5.0.0': + dependencies: + '@isaacs/balanced-match': 4.0.1 + '@isaacs/cliui@8.0.2': dependencies: string-width: 5.1.2 @@ -7765,6 +7808,15 @@ snapshots: package-json-from-dist: 1.0.1 path-scurry: 1.11.1 + glob@11.0.3: + dependencies: + foreground-child: 3.3.1 + jackspeak: 4.1.1 + minimatch: 10.0.3 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 2.0.0 + glob@7.2.3: dependencies: fs.realpath: 1.0.0 @@ -8087,6 +8139,10 @@ snapshots: optionalDependencies: '@pkgjs/parseargs': 0.11.0 + jackspeak@4.1.1: + dependencies: + '@isaacs/cliui': 8.0.2 + jest-environment-node@29.7.0: dependencies: '@jest/environment': 29.7.0 @@ -8326,6 +8382,8 @@ snapshots: lru-cache@10.4.3: {} + lru-cache@11.1.0: {} + lru-cache@5.1.1: dependencies: yallist: 3.1.1 @@ -8560,6 +8618,10 @@ snapshots: min-indent@1.0.1: {} + minimatch@10.0.3: + dependencies: + '@isaacs/brace-expansion': 5.0.0 + minimatch@3.0.8: dependencies: brace-expansion: 1.1.12 @@ -8795,6 +8857,11 @@ snapshots: lru-cache: 10.4.3 minipass: 7.1.2 + path-scurry@2.0.0: + dependencies: + lru-cache: 11.1.0 + minipass: 7.1.2 + path-type@4.0.0: {} pathe@2.0.3: {} @@ -9103,6 +9170,11 @@ snapshots: dependencies: glob: 7.2.3 + rimraf@6.0.1: + dependencies: + glob: 11.0.3 + package-json-from-dist: 1.0.1 + rollup@4.44.1: dependencies: '@types/estree': 1.0.8