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