Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
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
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, authorization, 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
38 changes: 26 additions & 12 deletions packages/browser-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -212,30 +212,44 @@ 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 }]
const isLoadingOptInFlags = reflagClient.getIsLoadingOptInFlags();

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) throw new Error("Opt-in request failed or was skipped");
} catch (error) {
// Show an error in your UI; confirmation may fail after a remote change.
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.
Opt-in is not an authorization boundary. Requests use a publishable key and caller-supplied context IDs; company scope does not verify company membership or administrator permissions. Hiding the button from non-admins does not prevent direct requests. For admin-only or sensitive access, enforce authorization in your backend and use server-controlled access rules instead of public end-user opt-in.

`setOptIn` returns `Promise<Response | undefined>`:

- An OK `Response` is returned after refreshed flag state confirms the membership change. `flagsUpdated` listeners are notified when flags change.
- HTTP failures return a non-OK `Response` without refreshing flags. Check `response.ok`; `response.json()` can provide error details.
- Offline mode, invalid arguments, or a missing scoped context ID return `undefined` without sending a request.
- Network and confirmation failures reject the promise. A confirmation failure can happen after membership changed remotely.

Always check `response?.ok` and catch rejections, as in the example.

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.
For a bootstrapped client, the first `getOptInFlags()` or `getIsLoadingOptInFlags()` call requests a flags refresh only if opt-in metadata is missing. The list call returns the currently available list synchronously, and the loading getter returns `true` until that refresh succeeds or fails. Complete bootstrapped metadata is immediately available without an extra request. 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.

A failed metadata refresh clears the loading state without exposing a separate error state. An empty list may mean unavailable data, not just no eligible flags. Calling the getters again does not retry the on-demand refresh for the same context. Call `reflagClient.refresh()` to retry manually, check for an `undefined` result, and track the retry's pending/error state in your UI.

## 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 @@ -333,7 +347,7 @@ The `bootstrappedState` object contains:

If you want live flag updates to continue working after bootstrapping, use a recent `@reflag/node-sdk` so `getFlagsForBootstrap()` includes `flagStateVersion`.

If a bootstrapped application requests opt-in flags, the browser SDK performs one flags refresh. Applications that do not request opt-in data do not make this request.
If a bootstrapped application requests opt-in flags and its state lacks opt-in metadata, the browser SDK requests a flags refresh. Node SDK bootstrap data currently lacks this metadata. Complete metadata, or applications that do not request opt-in data, do not require this extra request.

If you previously used `bootstrappedFlags`, migrate like this:

Expand All @@ -357,7 +371,7 @@ 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.
> After bootstrapping, on-demand opt-in metadata and live flag updates are fetched using the browser-visible context. If your snapshot depends on server-only or secret context, refreshed flags may differ. Disabling `enableLiveFlagUpdates` does not prevent the opt-in metadata refresh; only request opt-in data if browser-side re-evaluation is appropriate.

This eliminates loading states and removes the initial render's dependency on the flags API.

Expand Down
13 changes: 11 additions & 2 deletions packages/browser-sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,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 @@ -1254,6 +1254,15 @@ 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.
*
* Context IDs are supplied by the caller, not authenticated user identities.
* Company scope does not enforce application roles or admin permissions.
*/
async setOptIn(
flagKey: string,
Expand Down Expand Up @@ -1333,7 +1342,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
74 changes: 56 additions & 18 deletions packages/react-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -602,11 +602,11 @@ 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 only if the bootstrapped state lacks opt-in metadata. It returns `isLoading: true` (or suspends) until that refresh settles. Complete metadata is immediately available without this extra request.
>
> 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.
> 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. Disabling `enableLiveFlagUpdates` does not prevent the opt-in metadata refresh; only request opt-in data if browser-side re-evaluation is appropriate.

## Hooks

Expand Down Expand Up @@ -682,40 +682,78 @@ 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 { useState } from "react";
import {
type OptInFlag,
useIsLoading,
useOptInFlags,
useSetOptIn,
} from "@reflag/react-sdk";

function OptInList() {
const isProviderLoading = useIsLoading();
const { flags, isLoading } = useOptInFlags();
const setOptIn = useSetOptIn();
const [isUpdating, setIsUpdating] = useState(false);
const [error, setError] = useState<string | null>(null);

async function updateOptIn(flag: OptInFlag) {
setError(null);
setIsUpdating(true);
try {
const response = await setOptIn(flag.key, { optedIn: !flag.userOptedIn });
if (!response?.ok) throw new Error("Opt-in request failed");
} catch {
setError(`Could not update ${flag.name}. Please try again.`);
} finally {
setIsUpdating(false);
}
}

// This is only true with ReflagBootstrappedProvider while the SDK fetches
// opt-in metadata on first use.
if (isLoading) {
return <Spinner />;
if (isProviderLoading || isLoading) {
return <p>Loading opt-in flags…</p>;
}

if (flags.length === 0) {
return <p>No opt-in flags are available.</p>;
return <p>No opt-in flags to show. The list may also be unavailable.</p>;
}

return flags.map((flag) => (
<button
key={flag.key}
onClick={() => setOptIn(flag.key, { optedIn: !flag.userOptedIn })}
>
{flag.userOptedIn ? "Cancel opt-in" : `Try ${flag.name}`}
</button>
));
return (
<>
{flags.map((flag) => (
<button
type="button"
key={flag.key}
disabled={isUpdating}
onClick={() => updateOptIn(flag)}
>
{flag.userOptedIn ? "Cancel opt-in" : `Try ${flag.name}`}
</button>
))}
{error && <p role="alert">{error}</p>}
</>
);
}
```

By default, `useSetOptIn()` 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, 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.
Opt-in is not an authorization boundary. Requests use a publishable key and caller-supplied context IDs; company scope does not verify company membership or administrator permissions. Hiding the button from non-admins does not prevent direct requests. For admin-only or sensitive access, enforce authorization in your backend and use server-controlled access rules instead of public end-user opt-in.

`setOptIn` returns `Promise<Response | undefined>`:

- An OK `Response` is returned after refreshed flag state confirms the membership change. Subscribers are notified when flags change; React may not have committed the render yet.
- HTTP failures return a non-OK `Response` without refreshing flags. Check `response.ok`; `response.json()` can provide error details.
- Offline mode, invalid arguments, or a missing scoped context ID return `undefined` without sending a request.
- Network and confirmation failures reject the promise. A confirmation failure can happen after membership changed remotely.

Always check `response?.ok` and catch rejections, as in the example.

`useOptInFlags()` returns `{ flags, isLoading }`. With `ReflagBootstrappedProvider`, it fetches metadata on demand only when missing and reports `isLoading: true` until that refresh succeeds or fails. Node SDK bootstrap data currently lacks this metadata; complete bootstrapped metadata needs no extra request.

`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.
The hook does not expose fetch errors or throw them to an error boundary. After a failed metadata refresh, an empty list can mean unavailable data rather than no eligible flags. The on-demand refresh is not automatically attempted again for the same context. Use `useClient().refresh()` to retry, check for an `undefined` result, and manage retry pending/error state separately. See the [opt-in guide](https://docs.reflag.com/guides/self-opt-in) for a complete example with a reload button.

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
19 changes: 17 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,15 @@ 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.
* Complete bootstrapped opt-in metadata needs no extra request.
* 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.
*
* Fetch failures end loading without exposing an error, so an empty list can
* also mean unavailable data. Re-rendering does not retry a failed on-demand
* fetch for the same context. Call the client returned by {@link useClient}'s
* `refresh()` method to retry and manage the retry's pending/error state yourself.
*/
export function useOptInFlags(
options: UseOptInFlagsOptions = {},
Expand Down Expand Up @@ -816,6 +826,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