Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions packages/react-native/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,12 +18,12 @@ export const setUserId = async (userId: string): Promise<void> => {
await queue.wait();
};

export const setAttribute = async (key: string, value: string): Promise<void> => {
export const setAttribute = async (key: string, value: string | number | Date): Promise<void> => {
queue.add(Attributes.setAttributes, true, { [key]: value });
await queue.wait();
};

export const setAttributes = async (attributes: Record<string, string>): Promise<void> => {
export const setAttributes = async (attributes: Record<string, string | number | Date>): Promise<void> => {
queue.add(Attributes.setAttributes, true, attributes);
await queue.wait();
};
Expand Down
12 changes: 4 additions & 8 deletions packages/react-native/src/lib/common/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,21 +76,17 @@ export class ApiClient {

async createOrUpdateUser(userUpdateInput: {
userId: string;
attributes?: Record<string, string>;
attributes?: Record<string, string | number>;
}): Promise<Result<CreateOrUpdateUserResponse, ApiErrorResponse>> {
// transform all attributes to string if attributes are present into a new attributes copy
const attributes: Record<string, string> = {};
for (const key in userUpdateInput.attributes) {
attributes[key] = String(userUpdateInput.attributes[key]);
}

// Pass attributes as-is to preserve number types
// The backend will use the JS type to determine the attribute data type
return makeRequest(
this.appUrl,
`/api/v2/client/${this.environmentId}/user`,
"POST",
{
userId: userUpdateInput.userId,
attributes,
attributes: userUpdateInput.attributes,
},
this.isDebug
);
Expand Down
2 changes: 1 addition & 1 deletion packages/react-native/src/lib/survey/action.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,7 +114,7 @@ export const track = async (
if (!actionClass) {
return err({
code: "invalid_code",
message: `${code} action unknown. Please add this action in Formbricks first in order to use it in your code.`,
message: `Action with identifier '${code}' is unknown. Please add this action in Formbricks in order to use it via the SDK action tracking.`,
});
}

Expand Down
2 changes: 1 addition & 1 deletion packages/react-native/src/lib/survey/tests/action.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,7 +185,7 @@ describe("survey/action.ts", () => {
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."
"Action with identifier 'invalidCode' is unknown. Please add this action in Formbricks in order to use it via the SDK action tracking."
);
}
});
Expand Down
30 changes: 28 additions & 2 deletions packages/react-native/src/lib/user/attribute.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,38 @@
import { UpdateQueue } from "@/lib/user/update-queue";
import { type NetworkError, type Result, okVoid } from "@/types/error";

/**
* Sets attributes on the current user/contact.
*
* Attribute types are determined by the JavaScript value type:
* - String values -> string attribute
* - Number values -> number attribute
* - Date objects -> date attribute (converted to ISO string)
* - ISO 8601 date strings -> date attribute
*
* On first write to a new attribute, the type is set based on the JS value type.
* On subsequent writes, the value must match the existing attribute type.
*
* @param attributes - Key-value pairs where values can be strings, numbers, or Date objects
Comment thread
pandeymangg marked this conversation as resolved.
*/
export const setAttributes = async (
attributes: Record<string, string>
attributes: Record<string, string | number | Date>
// eslint-disable-next-line @typescript-eslint/require-await -- we want to use promises here
): Promise<Result<void, NetworkError>> => {
// Normalize values: convert Date to ISO string, preserve numbers as numbers
const normalizedAttributes: Record<string, string | number> = {};
for (const [key, value] of Object.entries(attributes)) {
if (value instanceof Date) {
// Date objects become ISO strings (backend will detect as date type)
normalizedAttributes[key] = value.toISOString();
} else {
// Preserve strings as strings, numbers as numbers
normalizedAttributes[key] = value;
}
}

const updateQueue = UpdateQueue.getInstance();
await updateQueue.updateAttributes(attributes);
await updateQueue.updateAttributes(normalizedAttributes);
void updateQueue.processUpdates();
return okVoid();
};
50 changes: 50 additions & 0 deletions packages/react-native/src/lib/user/tests/attribute.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,5 +80,55 @@ describe("User Attributes", () => {
expect(mockUpdateQueue.processUpdates).toHaveBeenCalled();
// The function returns before processUpdates completes due to void operator
});

test("converts Date values to ISO strings", async () => {
const testDate = new Date("2026-01-15T10:30:00.000Z");
const attributes = { createdAt: testDate };

await setAttributes(attributes);

expect(mockUpdateQueue.updateAttributes).toHaveBeenCalledWith({
createdAt: "2026-01-15T10:30:00.000Z",
});
});

test("preserves number values as numbers", async () => {
const attributes = { age: 25, score: 99.5 };

await setAttributes(attributes);

expect(mockUpdateQueue.updateAttributes).toHaveBeenCalledWith({
age: 25,
score: 99.5,
});
});

test("preserves string values as strings", async () => {
const attributes = { name: "Alice", role: "admin" };

await setAttributes(attributes);

expect(mockUpdateQueue.updateAttributes).toHaveBeenCalledWith({
name: "Alice",
role: "admin",
});
});

test("normalizes mixed attribute types correctly", async () => {
const testDate = new Date("2026-06-01T00:00:00.000Z");
const attributes = {
name: "Bob",
age: 30,
joinedAt: testDate,
};

await setAttributes(attributes);

expect(mockUpdateQueue.updateAttributes).toHaveBeenCalledWith({
name: "Bob",
age: 30,
joinedAt: "2026-06-01T00:00:00.000Z",
});
});
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ describe("UpdateQueue", () => {

(sendUpdates as Mock).mockReturnValue({
ok: true,
data: { hasWarnings: false },
});

await updateQueue.updateAttributes({ name: mockAttributes.name });
Expand Down
77 changes: 75 additions & 2 deletions packages/react-native/src/lib/user/tests/update.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ vi.mock("@/lib/common/logger", () => ({
Logger: {
getInstance: vi.fn(() => ({
debug: vi.fn(),
error: vi.fn(),
})),
},
}));
Expand Down Expand Up @@ -104,7 +105,7 @@ describe("sendUpdatesToBackend", () => {
if (!result.ok) {
expect(result.error.code).toBe("network_error");
expect(result.error.message).toBe(
"Error updating user with userId user_123"
"Error updating user with userId user_123",
);
}
});
Expand All @@ -128,7 +129,7 @@ describe("sendUpdatesToBackend", () => {
appUrl: mockAppUrl,
environmentId: mockEnvironmentId,
updates: mockUpdates,
})
}),
).rejects.toThrow("Network error");
});
});
Expand All @@ -150,6 +151,7 @@ describe("sendUpdates", () => {

(Logger.getInstance as Mock).mockImplementation(() => ({
debug: vi.fn(),
error: vi.fn(),
}));
});

Expand All @@ -176,6 +178,9 @@ describe("sendUpdates", () => {
});

expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.hasWarnings).toBe(false);
}
});

test("handles backend errors", async () => {
Expand Down Expand Up @@ -204,6 +209,74 @@ describe("sendUpdates", () => {
}
});

test("returns hasWarnings true when backend returns errors", async () => {
const mockResponse = {
ok: true,
data: {
state: {
data: {
userId: mockUserId,
attributes: mockAttributes,
},
expiresAt: new Date(Date.now() + 1000 * 60 * 30),
},
errors: ["Attribute 'invalidKey' has an invalid key format"],
},
};

(ApiClient as Mock).mockImplementation(function () {
return { createOrUpdateUser: vi.fn().mockResolvedValue(mockResponse) };
});

const result = await sendUpdates({
updates: { userId: mockUserId, attributes: mockAttributes },
});

expect(result.ok).toBe(true);
if (result.ok) {
expect(result.data.hasWarnings).toBe(true);
}
});

test("logs backend errors at error level and messages at debug level", async () => {
const mockError = vi.fn();
const mockDebug = vi.fn();
(Logger.getInstance as Mock).mockImplementation(() => ({
debug: mockDebug,
error: mockError,
}));

const mockResponse = {
ok: true,
data: {
state: {
data: {
userId: mockUserId,
attributes: mockAttributes,
},
expiresAt: new Date(Date.now() + 1000 * 60 * 30),
},
messages: ["Email attribute already exists"],
errors: ["Attribute 'badKey' has an invalid format"],
},
};

(ApiClient as Mock).mockImplementation(function () {
return { createOrUpdateUser: vi.fn().mockResolvedValue(mockResponse) };
});

await sendUpdates({
updates: { userId: mockUserId, attributes: mockAttributes },
});

expect(mockError).toHaveBeenCalledWith(
"Attribute 'badKey' has an invalid format",
);
expect(mockDebug).toHaveBeenCalledWith(
"User update message: Email attribute already exists",
);
});

test("handles unexpected errors", async () => {
(ApiClient as Mock).mockImplementation(function () {
return {
Expand Down
33 changes: 17 additions & 16 deletions packages/react-native/src/lib/user/update-queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ export class UpdateQueue {

private handleLanguageWithoutUserId(
currentUpdates: Partial<TUpdates> & { attributes?: TAttributes },
config: RNConfig
config: RNConfig,
): Partial<TUpdates> & { attributes?: TAttributes } {
if (!currentUpdates.attributes?.language) {
return currentUpdates;
Expand All @@ -80,7 +80,7 @@ export class UpdateQueue {
...config.get().user,
data: {
...config.get().user.data,
language: currentUpdates.attributes.language,
language: currentUpdates.attributes.language as string,
},
},
});
Expand All @@ -97,7 +97,7 @@ export class UpdateQueue {

private validateAttributesWithUserId(
currentUpdates: Partial<TUpdates> & { attributes?: TAttributes },
effectiveUserId: string | null | undefined
effectiveUserId: string | null | undefined,
): void {
const hasAttributes =
Object.keys(currentUpdates.attributes ?? {}).length > 0;
Expand All @@ -113,7 +113,7 @@ export class UpdateQueue {

private async sendUpdatesIfNeeded(
effectiveUserId: string | null | undefined,
currentUpdates: Partial<TUpdates> & { attributes?: TAttributes }
currentUpdates: Partial<TUpdates> & { attributes?: TAttributes },
): Promise<void> {
if (!effectiveUserId) {
return;
Expand All @@ -126,17 +126,18 @@ export class UpdateQueue {
},
});

if (result.ok) {
logger.debug("Updates sent successfully");
} else {
const err = result.error as {
status?: number;
code?: string;
message?: string;
};
if (!result.ok) {
const err = result.error;
logger.error(
`Failed to send updates: ${err?.message ?? "unknown error"}`
`Failed to send updates: ${err?.message ?? "unknown error"}`,
);

return;
}

// Only log success message if there were no warnings (e.g., skipped attributes)
if (!result.data.hasWarnings) {
logger.debug("Updates sent successfully");
}
}

Expand Down Expand Up @@ -168,7 +169,7 @@ export class UpdateQueue {
if (!effectiveUserId) {
currentUpdates = this.handleLanguageWithoutUserId(
currentUpdates,
config
config,
);
}

Expand All @@ -182,15 +183,15 @@ export class UpdateQueue {
resolve();
} catch (error: unknown) {
logger.error(
`Failed to process updates: ${error instanceof Error ? error.message : "Unknown error"}`
`Failed to process updates: ${error instanceof Error ? error.message : "Unknown error"}`,
);
reject(error as Error);
}
};

this.debounceTimeout = setTimeout(
() => void handler(),
this.DEBOUNCE_DELAY
this.DEBOUNCE_DELAY,
);
});
}
Expand Down
Loading