diff --git a/.changeset/gentle-context-updates.md b/.changeset/gentle-context-updates.md new file mode 100644 index 000000000..5e911fa2f --- /dev/null +++ b/.changeset/gentle-context-updates.md @@ -0,0 +1,7 @@ +--- +"@reflag/browser-sdk": patch +"@reflag/react-sdk": patch +"@reflag/vue-sdk": patch +--- + +Accept JSON-compatible attributes consistently in context update methods and React/Vue update hooks. Typed user and company contexts can be passed directly, and partial updates still work. Keep user/company IDs restricted to strings or numbers. Also align other-context updates and deprecated provider props with the existing context types. diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 4f63bbbef..f83beb723 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -2,7 +2,13 @@ import { deepEqual } from "fast-equals"; import { BulkEvent, BulkQueue } from "./bulkQueue"; import { API_BASE_URL, APP_BASE_URL, IS_SERVER, SDK_VERSION } from "./config"; -import { ReflagContext, ReflagDeprecatedContext } from "./context"; +import { + CompanyContext, + ContextValue, + ReflagContext, + ReflagDeprecatedContext, + UserContext, +} from "./context"; import { AutoFeedback, Feedback, @@ -841,7 +847,7 @@ export class ReflagClient { * * @param user */ - async updateUser(user: { [key: string]: string | number | undefined }) { + async updateUser(user: Partial) { const newUserContext = { ...this.context.user, ...user, @@ -865,7 +871,7 @@ export class ReflagClient { * * @param company The company details. */ - async updateCompany(company: { [key: string]: string | number | undefined }) { + async updateCompany(company: Partial) { const newCompanyContext = { ...this.context.company, ...company, @@ -889,9 +895,7 @@ export class ReflagClient { * * @param otherContext Additional context. */ - async updateOtherContext(otherContext: { - [key: string]: string | number | undefined; - }) { + async updateOtherContext(otherContext: Record) { const newOtherContext = { ...this.context.other, ...otherContext, diff --git a/packages/browser-sdk/test/client.test.ts b/packages/browser-sdk/test/client.test.ts index efe31130d..7a8ce87c9 100644 --- a/packages/browser-sdk/test/client.test.ts +++ b/packages/browser-sdk/test/client.test.ts @@ -2,6 +2,7 @@ import { http, HttpResponse } from "msw"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ReflagClient } from "../src/client"; +import { CompanyContext, ContextValue, UserContext } from "../src/context"; import { FlagsClient } from "../src/flag/flags"; import { HttpClient } from "../src/httpClient"; import { deferred } from "./deferred"; @@ -66,6 +67,37 @@ describe("ReflagClient", () => { vi.unstubAllGlobals(); }); + it("accepts JSON-compatible values in user, company and other context updates", async () => { + const user: UserContext = { + id: "user1", + enabled: true, + tags: ["beta", 1, null], + preferences: { theme: "dark", omitted: undefined }, + cleared: null, + }; + const company: CompanyContext = { + id: "company1", + enterprise: false, + settings: { regions: ["eu", "us"] }, + }; + const other: Record = { + preview: true, + settings: { locale: "en" }, + }; + + await client.updateUser(user); + await client.updateCompany(company); + await client.updateOtherContext(other); + await client.updateUser({ enabled: false }); + + expect(client.getContext()).toEqual({ + user: { ...user, enabled: false }, + company, + other, + }); + expect(flagClientSetContext).toHaveBeenLastCalledWith(client.getContext()); + }); + describe("updateUser", () => { it("should update the user context", async () => { // and send new user data and trigger flag update diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index 1c41a57a7..f51aae8ab 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -534,7 +534,7 @@ export type ReflagProps = ReflagPropsBase & * Context which is not related to a user or a company. * @deprecated Use `context` instead, this property will be removed in the next major version */ - otherContext?: Record; + otherContext?: ReflagContext["other"]; }; /** @@ -905,7 +905,7 @@ export function useSendFeedback() { */ export function useUpdateUser() { const client = useClient(); - return (opts: { [key: string]: string | number | undefined }) => + return (opts: Parameters[0]) => client.updateUser(opts); } @@ -925,7 +925,7 @@ export function useUpdateUser() { export function useUpdateCompany() { const client = useClient(); - return (opts: { [key: string]: string | number | undefined }) => + return (opts: Parameters[0]) => client.updateCompany(opts); } @@ -945,7 +945,7 @@ export function useUpdateCompany() { */ export function useUpdateOtherContext() { const client = useClient(); - return (opts: { [key: string]: string | number | undefined }) => + return (opts: Parameters[0]) => client.updateOtherContext(opts); } diff --git a/packages/react-sdk/test/context-update-types.tsx b/packages/react-sdk/test/context-update-types.tsx new file mode 100644 index 000000000..e67f98d80 --- /dev/null +++ b/packages/react-sdk/test/context-update-types.tsx @@ -0,0 +1,57 @@ +import type { + CompanyContext, + ContextValue, + UserContext, +} from "@reflag/browser-sdk"; +import { + ReflagProps, + useClient, + useUpdateCompany, + useUpdateOtherContext, + useUpdateUser, +} from "@reflag/react-sdk"; + +export function ContextUpdateTypes() { + const client = useClient(); + const updateUser = useUpdateUser(); + const updateCompany = useUpdateCompany(); + const updateOtherContext = useUpdateOtherContext(); + const user: UserContext = { + id: "user-1", + enabled: true, + tags: ["beta", 1, null], + preferences: { theme: "dark", omitted: undefined }, + cleared: null, + }; + const company: CompanyContext = { ...user, id: "company-1" }; + const other: Record = { + preview: true, + settings: { locale: "en" }, + }; + + void client.updateUser(user); + void client.updateCompany(company); + void client.updateOtherContext(other); + void updateUser(user); + void updateCompany(company); + void updateOtherContext(other); + void updateUser({ enabled: false }); + void updateCompany({ tags: ["enterprise"] }); + + const options: ReflagProps = { + publishableKey: "test-key", + otherContext: other, + }; + void options; + + // @ts-expect-error user IDs must remain strings or numbers + void client.updateUser({ id: true }); + // @ts-expect-error company IDs must remain strings or numbers + void updateCompany({ id: { nested: "id" } }); + // @ts-expect-error context values must be JSON-compatible + void updateUser({ callback: () => true }); + // @ts-expect-error undefined array entries are not JSON context values + void updateOtherContext({ tags: [undefined] }); + + return null; +} diff --git a/packages/react-sdk/tsconfig.type-tests.json b/packages/react-sdk/tsconfig.type-tests.json index 4eb497bbc..b2307f009 100644 --- a/packages/react-sdk/tsconfig.type-tests.json +++ b/packages/react-sdk/tsconfig.type-tests.json @@ -7,5 +7,5 @@ "@reflag/react-sdk": ["./src/index.tsx"] } }, - "include": ["src", "test/opt-in-types.tsx"] + "include": ["src", "test/opt-in-types.tsx", "test/context-update-types.tsx"] } diff --git a/packages/vue-sdk/src/hooks.ts b/packages/vue-sdk/src/hooks.ts index 93eb047eb..8792c7c7c 100644 --- a/packages/vue-sdk/src/hooks.ts +++ b/packages/vue-sdk/src/hooks.ts @@ -278,7 +278,7 @@ export function useSendFeedback() { */ export function useUpdateUser() { const client = useClient(); - return (opts: { [key: string]: string | number | undefined }) => + return (opts: Parameters[0]) => client.updateUser(opts); } @@ -304,7 +304,7 @@ export function useUpdateUser() { */ export function useUpdateCompany() { const client = useClient(); - return (opts: { [key: string]: string | number | undefined }) => + return (opts: Parameters[0]) => client.updateCompany(opts); } @@ -330,7 +330,7 @@ export function useUpdateCompany() { */ export function useUpdateOtherContext() { const client = useClient(); - return (opts: { [key: string]: string | number | undefined }) => + return (opts: Parameters[0]) => client.updateOtherContext(opts); } diff --git a/packages/vue-sdk/src/types.ts b/packages/vue-sdk/src/types.ts index 3ec8794d2..afa358171 100644 --- a/packages/vue-sdk/src/types.ts +++ b/packages/vue-sdk/src/types.ts @@ -161,7 +161,7 @@ export type ReflagProps = ReflagInitOptionsBase & * Context which is not related to a user or a company. * @deprecated Use `context` instead, this property will be removed in the next major version */ - otherContext?: Record; + otherContext?: ReflagContext["other"]; }; /** diff --git a/packages/vue-sdk/test/context-update-types.ts b/packages/vue-sdk/test/context-update-types.ts new file mode 100644 index 000000000..7692d9904 --- /dev/null +++ b/packages/vue-sdk/test/context-update-types.ts @@ -0,0 +1,57 @@ +import type { + CompanyContext, + ContextValue, + UserContext, +} from "@reflag/browser-sdk"; +import { + ReflagProps, + useClient, + useUpdateCompany, + useUpdateOtherContext, + useUpdateUser, +} from "@reflag/vue-sdk"; + +export function ContextUpdateTypes() { + const client = useClient(); + const updateUser = useUpdateUser(); + const updateCompany = useUpdateCompany(); + const updateOtherContext = useUpdateOtherContext(); + const user: UserContext = { + id: "user-1", + enabled: true, + tags: ["beta", 1, null], + preferences: { theme: "dark", omitted: undefined }, + cleared: null, + }; + const company: CompanyContext = { ...user, id: "company-1" }; + const other: Record = { + preview: true, + settings: { locale: "en" }, + }; + + void client.updateUser(user); + void client.updateCompany(company); + void client.updateOtherContext(other); + void updateUser(user); + void updateCompany(company); + void updateOtherContext(other); + void updateUser({ enabled: false }); + void updateCompany({ tags: ["enterprise"] }); + + const options: ReflagProps = { + publishableKey: "test-key", + otherContext: other, + }; + void options; + + // @ts-expect-error user IDs must remain strings or numbers + void client.updateUser({ id: true }); + // @ts-expect-error company IDs must remain strings or numbers + void updateCompany({ id: { nested: "id" } }); + // @ts-expect-error context values must be JSON-compatible + void updateUser({ callback: () => true }); + // @ts-expect-error undefined array entries are not JSON context values + void updateOtherContext({ tags: [undefined] }); + + return updateUser; +} diff --git a/packages/vue-sdk/tsconfig.type-tests.json b/packages/vue-sdk/tsconfig.type-tests.json index 99d5ef84d..5cb72ac19 100644 --- a/packages/vue-sdk/tsconfig.type-tests.json +++ b/packages/vue-sdk/tsconfig.type-tests.json @@ -7,5 +7,5 @@ "@reflag/vue-sdk": ["./src/index.ts"] } }, - "include": ["src", "test/opt-in-types.ts"] + "include": ["src", "test/opt-in-types.ts", "test/context-update-types.ts"] }