Skip to content
Merged
5 changes: 5 additions & 0 deletions .changeset/tidy-opt-in-responses.md
Original file line number Diff line number Diff line change
@@ -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.
1 change: 1 addition & 0 deletions docs.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
29 changes: 19 additions & 10 deletions packages/browser-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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.

Expand Down
10 changes: 8 additions & 2 deletions packages/browser-sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
/**
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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 },
});
Expand Down
66 changes: 65 additions & 1 deletion packages/browser-sdk/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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[] = [];

Expand Down
46 changes: 31 additions & 15 deletions packages/react-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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 <Spinner />;
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 <p>No opt-in flags are available.</p>;
if (isProviderLoading || isLoading) {
return <p>Loading…</p>;
}

return flags.map((flag) => (
<button
key={flag.key}
onClick={() => setOptIn(flag.key, { optedIn: !flag.userOptedIn })}
>
<button type="button" key={flag.key} onClick={() => toggleOptIn(flag)}>
{flag.userOptedIn ? "Cancel opt-in" : `Try ${flag.name}`}
</button>
));
Expand All @@ -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.

Expand Down
16 changes: 14 additions & 2 deletions packages/react-sdk/src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<BrowserOptInFlag, "key"> & {
key: FlagKey;
Expand Down Expand Up @@ -771,9 +775,12 @@ export function useFlag<TKey extends FlagKey>(
*
* 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 = {},
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading