diff --git a/.changeset/tidy-opt-in-responses.md b/.changeset/tidy-opt-in-responses.md new file mode 100644 index 000000000..2a57085e9 --- /dev/null +++ b/.changeset/tidy-opt-in-responses.md @@ -0,0 +1,5 @@ +--- +"@reflag/browser-sdk": patch +--- + +Preserve the response body returned by `setOptIn()` on HTTP errors so callers can read error details. Clarify opt-in loading, retry, and mutation result handling in the SDK documentation. diff --git a/docs.sh b/docs.sh index 9b57fa72b..70f103048 100755 --- a/docs.sh +++ b/docs.sh @@ -28,6 +28,7 @@ do sed -r "$SEDCOMMAND" "$file" > "$file.fixed" rm "$file" mv "$file.fixed" "$file" + node ./scripts/fix-opt-in-docs.mjs "$file" fi # Create a temporary file for processing diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index bc7364a98..dc09ae510 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -212,30 +212,37 @@ If a flag has end-user opt-in enabled in Reflag, you can list the opt-in options ```ts const optInFlags = reflagClient.getOptInFlags(); -const isLoadingOptInFlags = reflagClient.getIsLoadingOptInFlags(); // [{ key, name, description, isEnabled, userOptedIn, companyOptedIn, isOptedIn }] -await reflagClient.setOptIn("huddle", { optedIn: true }); -await reflagClient.setOptIn("huddle", { optedIn: false }); - -await reflagClient.setOptIn("huddle", { - optedIn: true, - scope: "company", -}); +try { + const response = await reflagClient.setOptIn("huddle", { + optedIn: true, // Use false to cancel this scope's opt-in. + scope: "user", // Use "company" to change the current company's opt-in. + }); + if (!response?.ok) { + console.error("Could not update opt-in"); + } +} catch (error) { + console.error("Could not update opt-in", error); +} ``` By default, `setOptIn()` changes the opt-in for the current user, so the current context must include a `user.id`. To manage the current company's opt-in instead, pass `scope: "company"`; the context must then include a `company.id`. User and company opt-ins are managed independently. Setting `optedIn` to `false` removes the opt-in only for the selected scope. For example, cancelling a user's opt-in does not change the company's opt-in for the same flag. -`setOptIn` returns a promise so you can wait for the new membership state to be synchronized. It resolves after the latest flag state has been applied locally, the requested membership change has been confirmed, and `flagsUpdated` listeners have been notified. +`setOptIn` returns a promise so you can wait for the new membership state to be synchronized. On success, it resolves after the refreshed flag state has been applied locally, the requested membership change has been confirmed, and `flagsUpdated` listeners have been notified when flags change. + +The promise resolves to a `Response`, or `undefined` if skipped due to invalid input, offline mode etc. Check `response?.ok` for success. The `description` comes from the dedicated SDK-facing opt-in description configured in Reflag. -For a bootstrapped client, the first `getOptInFlags()` or `getIsLoadingOptInFlags()` call starts one flags refresh. The list call returns the currently available list synchronously, and the loading getter returns `true` until the refresh succeeds or fails. Normal initialization already exposes loading through the client's state. +When using bootstrapped flags, the first `getOptInFlags()` or `getIsLoadingOptInFlags()` call requests a flags refresh. The list call returns the currently available list synchronously, and the loading getter returns `true` until that refresh succeeds or fails. Normal initialization also exposes loading through the client's state. Listen for `optInFlagsLoadingUpdated` to update UI when this loading state changes. `flagsUpdated` is emitted when a successful refresh updates the list. +If fetching opt-in metadata fails, loading ends without exposing an error. Call `reflagClient.refresh()` to retry. + ## Remote config Remote config is a dynamic and flexible approach to configuring flag behavior outside of your app – without needing to re-deploy it. @@ -358,6 +365,8 @@ const client = new ReflagClient({ > [!NOTE] > After bootstrapping, any live flag updates are fetched directly by the browser SDK from Reflag using the browser-visible context. If your bootstrapped snapshot depends on server-only or secret context that is not available in the browser, later live refreshes may differ. In that case, keep `enableLiveFlagUpdates` disabled. +> +> Requesting opt-in flags also triggers a browser-side refresh, even when `enableLiveFlagUpdates` is disabled. This eliminates loading states and removes the initial render's dependency on the flags API. diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 994fd7ce2..d4f0f80fe 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -436,7 +436,7 @@ export type FlagRemoteConfig = | { key: undefined; payload: undefined }; /** - * Represents a flag. + * Options for changing the current user or company's opt-in membership. */ export type SetOptInOptions = { /** @@ -1272,6 +1272,12 @@ export class ReflagClient { /** * Set whether the current user or company has opted into a flag. + * + * A successful Response is returned after the refreshed flag state confirms + * the membership change. HTTP failures return a non-OK Response without + * refreshing flags. Offline mode, invalid arguments, or missing scoped context + * return undefined. Network and confirmation failures reject the promise; + * a confirmation failure may occur after membership changed remotely. */ async setOptIn( flagKey: string, @@ -1351,7 +1357,7 @@ export class ReflagClient { if (!res.ok) { await logResponseError({ logger: this.logger, - res, + res: res.clone(), message: "set opt-in request failed", extra: { flagKey, optedIn: options.optedIn, scope }, }); diff --git a/packages/browser-sdk/test/client.test.ts b/packages/browser-sdk/test/client.test.ts index 9b9467e11..efe31130d 100644 --- a/packages/browser-sdk/test/client.test.ts +++ b/packages/browser-sdk/test/client.test.ts @@ -421,7 +421,7 @@ describe("ReflagClient", () => { expect(loadingUpdated).toHaveBeenLastCalledWith(false); }); - it("stops loading opt-in flags when the metadata refresh fails", async () => { + it("stops loading after a metadata failure and allows a manual retry", async () => { server.use( http.get("https://front.reflag.com/features/evaluated", () => HttpResponse.json({ success: false }, { status: 500 }), @@ -449,6 +449,26 @@ describe("ReflagClient", () => { }); expect(httpClientGet).toHaveBeenCalledTimes(1); expect(client.getOptInFlags()).toEqual([]); + expect(client.getIsLoadingOptInFlags()).toBe(false); + expect(httpClientGet).toHaveBeenCalledTimes(1); + + server.use( + http.get("https://front.reflag.com/features/evaluated", () => + HttpResponse.json({ success: true, features: optInFlags(false) }), + ), + ); + const flagsUpdated = vi.fn(); + client.on("flagsUpdated", flagsUpdated); + + const refreshed = await client.refresh(); + + expect(refreshed).toBeDefined(); + expect(httpClientGet).toHaveBeenCalledTimes(2); + expect(client.getIsLoadingOptInFlags()).toBe(false); + expect(client.getOptInFlags()).toEqual([ + expect.objectContaining({ key: "optInFlag", userOptedIn: false }), + ]); + expect(flagsUpdated).toHaveBeenCalledTimes(1); }); it("keeps opt-in loading tied to the newest context fetch", async () => { @@ -725,6 +745,50 @@ describe("ReflagClient", () => { expect(flagsUpdated).toHaveBeenCalledTimes(1); }); + it("returns readable HTTP errors without refreshing or changing flags", async () => { + const errorBody = { + success: false, + error: { + code: "OPT_IN_NOT_ALLOWED", + message: "Opt-in is not enabled for this flag", + }, + }; + server.use( + http.post("https://front.reflag.com/flags/opt-in", () => + HttpResponse.json(errorBody, { status: 403 }), + ), + ); + client = new ReflagClient({ + publishableKey: "test-key-opt-in-http-error", + user: { id: "user1" }, + enableTracking: false, + feedback: { enableAutoFeedback: false }, + bootstrappedFlags: optInFlags(false), + }); + await client.initialize(); + const flagsUpdated = vi.fn(); + client.on("flagsUpdated", flagsUpdated); + const logError = vi.spyOn(client.logger, "error"); + + const response = await client.setOptIn("optInFlag", { optedIn: true }); + + expect(response?.ok).toBe(false); + expect(response?.status).toBe(403); + expect(response?.bodyUsed).toBe(false); + await expect(response!.json()).resolves.toEqual(errorBody); + expect(httpClientGet).not.toHaveBeenCalled(); + expect(flagsUpdated).not.toHaveBeenCalled(); + expect(client.getOptInFlags()[0].userOptedIn).toBe(false); + expect(logError).toHaveBeenCalledWith( + expect.stringContaining("OPT_IN_NOT_ALLOWED"), + expect.objectContaining({ + apiErrorCode: "OPT_IN_NOT_ALLOWED", + status: 403, + }), + ); + logError.mockRestore(); + }); + it("cancels opt-in and refreshes flags at the returned state version", async () => { const requests: string[] = []; diff --git a/packages/react-sdk/README.md b/packages/react-sdk/README.md index 109a3065b..e7d61fcb0 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -602,11 +602,13 @@ function App({ bootstrapData }: AppProps) { > [!Note] > When using `ReflagBootstrappedProvider`, pass the entire object returned by `getFlagsForBootstrap()` directly as the `flags` prop. The context is extracted from `flags.context`, and `flags.flagStateVersion` is used when present. > -> With `ReflagBootstrappedProvider`, `useOptInFlags()` triggers one flags refresh and returns `isLoading: true` (or suspends) until it settles. No refresh occurs unless the hook is used. +> With `ReflagBootstrappedProvider`, `useOptInFlags()` requests a flags refresh on first use. It returns `isLoading: true` (or suspends) until that refresh settles. > > If you want live flag updates to continue working after bootstrapping, use a recent `@reflag/node-sdk` so `getFlagsForBootstrap()` includes `flagStateVersion`. > -> The on-demand browser refresh and any later live flag updates use the browser-visible context. If your bootstrapped snapshot depends on server-only or secret context that is not available in the browser, refreshed flags may differ. In that case, keep `enableLiveFlagUpdates` disabled. +> After bootstrapping, any live flag updates are fetched directly by the browser SDK from Reflag using the browser-visible context. If your bootstrapped snapshot depends on server-only or secret context that is not available in the browser, later live refreshes may differ. In that case, keep `enableLiveFlagUpdates` disabled. +> +> Requesting opt-in flags also triggers a browser-side refresh, even when `enableLiveFlagUpdates` is disabled. ## Hooks @@ -682,27 +684,37 @@ You can also opt in for a single call with `useFlag("huddle", { suspense: true } Use these hooks to build an end-user opt-in UI for flags where opt-in is enabled in Reflag. ```tsx -import { useOptInFlags, useSetOptIn } from "@reflag/react-sdk"; +import { + type OptInFlag, + useIsLoading, + useOptInFlags, + useSetOptIn, +} from "@reflag/react-sdk"; function OptInList() { + const isProviderLoading = useIsLoading(); const { flags, isLoading } = useOptInFlags(); const setOptIn = useSetOptIn(); - // This is only true with ReflagBootstrappedProvider while the SDK fetches - // opt-in metadata on first use. - if (isLoading) { - return ; + async function toggleOptIn(flag: OptInFlag) { + try { + const response = await setOptIn(flag.key, { + optedIn: !flag.userOptedIn, + }); + if (!response?.ok) { + console.error("Could not update opt-in"); + } + } catch (error) { + console.error("Could not update opt-in", error); + } } - if (flags.length === 0) { - return

No opt-in flags are available.

; + if (isProviderLoading || isLoading) { + return

Loading…

; } return flags.map((flag) => ( - )); @@ -713,9 +725,13 @@ By default, `useSetOptIn()` changes the opt-in for the current user, so the curr User and company opt-ins are managed independently. Setting `optedIn` to `false` removes the opt-in only for the selected scope. For example, cancelling a user's opt-in does not change the company's opt-in for the same flag. -`setOptIn` returns a promise so you can wait for the new membership state to be synchronized. It resolves after the latest flag state has been applied, the requested membership change has been confirmed, and components using `useOptInFlags()` have been notified. React schedules the resulting render normally, so it may not yet be committed when the promise resolves. +`setOptIn` returns a promise so you can wait for the new membership state to be synchronized. On success, it resolves after the refreshed flag state has been applied locally, the requested membership change has been confirmed, and subscribers have been notified when flags change. React may not have committed the resulting render yet. + +The promise resolves to a `Response`, or `undefined` if skipped due to invalid input, offline mode etc. Check `response?.ok` for success. + +`useOptInFlags()` returns `{ flags, isLoading }`. With `ReflagBootstrappedProvider`, it fetches opt-in metadata on first use and reports `isLoading: true` until that refresh succeeds or fails. -`useOptInFlags()` returns `{ flags, isLoading }`. With `ReflagBootstrappedProvider`, the hook fetches opt-in metadata on first use and reports `isLoading: true` until the flags refresh succeeds or fails. +If fetching opt-in metadata fails, loading ends without exposing an error. Call `client.refresh()` on the client returned by `useClient()` to retry. With a regular `ReflagProvider`, opt-in metadata arrives as part of the normal initial flags request, so `useOptInFlags().isLoading` remains `false`. Use the general `useIsLoading()` hook or the provider's `loadingComponent` for that initial loading state. diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index 31e9bde3f..1c41a57a7 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -133,6 +133,10 @@ export type FlagKey = keyof TypedFlags; /** * An opt-in-enabled flag for the generated React SDK flag definitions. + * + * Includes all fields from {@link BrowserOptInFlag}: `name`, `description`, + * `isEnabled`, `userOptedIn`, `companyOptedIn`, and `isOptedIn`. + * Only `key` is narrowed to the generated {@link FlagKey} type. */ export type OptInFlag = Omit & { key: FlagKey; @@ -771,9 +775,12 @@ export function useFlag( * * The loading state is only used with `ReflagBootstrappedProvider` while * opt-in metadata is fetched on demand. Regular providers load opt-in metadata - * with the initial flags. + * with the initial flags; use {@link useIsLoading} for their loading state. * When suspense is enabled for the provider or this hook, it suspends instead - * of returning a loading result. + * of returning a loading result. A Suspense boundary alone does not enable it. + * + * If fetching opt-in metadata fails, loading ends without exposing an error. + * Call `refresh()` on the client returned by {@link useClient} to retry. */ export function useOptInFlags( options: UseOptInFlagsOptions = {}, @@ -816,6 +823,11 @@ export function useOptInFlags( /** * Returns a function to set whether the current user or company has opted into a flag. + * + * Check the returned Response's `ok` property and catch promise rejections. + * HTTP failures return a non-OK Response; offline mode, invalid arguments, or + * missing scoped context return undefined. Confirmation failures can reject + * after the membership changed remotely. See {@link ReflagClient.setOptIn}. */ export function useSetOptIn() { const client = useClient(); diff --git a/packages/vue-sdk/README.md b/packages/vue-sdk/README.md index bab48615f..d01e726ff 100644 --- a/packages/vue-sdk/README.md +++ b/packages/vue-sdk/README.md @@ -259,7 +259,7 @@ You'll typically generate the `bootstrappedFlags` object on your server using th If you want live flag updates to continue working after bootstrapping, use a recent `@reflag/node-sdk` so `getFlagsForBootstrap()` includes `flagStateVersion`. -With `ReflagBootstrappedProvider`, `useOptInFlags()` triggers one flags refresh and reports `isLoading: true` until it settles. No refresh occurs unless the composable is used. +With `ReflagBootstrappedProvider`, `useOptInFlags()` requests a flags refresh on first use. It reports `isLoading: true` until that refresh settles. Here's an example using the Node.js SDK: @@ -293,7 +293,9 @@ const bootstrappedFlags = client.getFlagsForBootstrap(context); If the `flags` prop is not provided or is undefined, the provider will not initialize the client and will render in a non-loading state. > [!NOTE] -> The on-demand browser refresh and any later live flag updates use the browser-visible context. If your bootstrapped snapshot depends on server-only or secret context that is not available in the browser, refreshed flags may differ. In that case, keep `enableLiveFlagUpdates` disabled. +> After bootstrapping, any live flag updates are fetched directly by the browser SDK from Reflag using the browser-visible context. If your bootstrapped snapshot depends on server-only or secret context that is not available in the browser, later live refreshes may differ. In that case, keep `enableLiveFlagUpdates` disabled. +> +> Requesting opt-in flags also triggers a browser-side refresh, even when `enableLiveFlagUpdates` is disabled. ## `` component @@ -399,20 +401,39 @@ Use these composables to build an end-user opt-in UI for flags where opt-in is e ```vue