From 7d6d2df6b8c45856150721f0027be733cc375202 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Fri, 11 Sep 2026 14:59:20 +0200 Subject: [PATCH 1/8] fix(browser-sdk): preserve opt-in errors and clarify SDK docs --- .changeset/tidy-opt-in-responses.md | 5 ++ docs.sh | 1 + packages/browser-sdk/README.md | 38 ++++++++---- packages/browser-sdk/src/client.ts | 13 ++++- packages/browser-sdk/test/client.test.ts | 66 ++++++++++++++++++++- packages/react-sdk/README.md | 74 ++++++++++++++++++------ packages/react-sdk/src/index.tsx | 19 +++++- packages/vue-sdk/README.md | 54 ++++++++++++++--- packages/vue-sdk/src/hooks.ts | 13 ++++- packages/vue-sdk/src/types.ts | 7 +++ scripts/fix-opt-in-docs.mjs | 37 ++++++++++++ scripts/fix-opt-in-docs.test.mjs | 48 +++++++++++++++ 12 files changed, 331 insertions(+), 44 deletions(-) create mode 100644 .changeset/tidy-opt-in-responses.md create mode 100644 scripts/fix-opt-in-docs.mjs create mode 100644 scripts/fix-opt-in-docs.test.mjs diff --git a/.changeset/tidy-opt-in-responses.md b/.changeset/tidy-opt-in-responses.md new file mode 100644 index 000000000..b6f294d5f --- /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, authorization, 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..313cd6a82 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -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`: + +- 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. @@ -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: @@ -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. diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 66b942d65..029bbf24e 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -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 = { /** @@ -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, @@ -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 }, }); 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..475431e64 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -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 @@ -682,30 +682,57 @@ 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(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 ; + if (isProviderLoading || isLoading) { + return

Loading opt-in flags…

; } if (flags.length === 0) { - return

No opt-in flags are available.

; + return

No opt-in flags to show. The list may also be unavailable.

; } - return flags.map((flag) => ( - - )); + return ( + <> + {flags.map((flag) => ( + + ))} + {error &&

{error}

} + + ); } ``` @@ -713,9 +740,20 @@ 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. +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`: + +- 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. diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index 31e9bde3f..f2e0b28d8 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,15 @@ 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. + * 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 = {}, @@ -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(); diff --git a/packages/vue-sdk/README.md b/packages/vue-sdk/README.md index ee476c0e0..71746a474 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 only if the bootstrapped state lacks opt-in metadata. It reports `isLoading: true` until that refresh settles. Complete metadata is immediately available without this extra request. Here's an example using the Node.js SDK: @@ -293,7 +293,7 @@ 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. +> 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. ## `` component @@ -399,23 +399,50 @@ Use these composables to build an end-user opt-in UI for flags where opt-in is e ```vue ``` @@ -424,9 +451,20 @@ 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. Vue 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`: + +- An OK `Response` is returned after refreshed flag state confirms the membership change. Subscribers are notified when flags change; Vue 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 }`, where both values are computed refs. 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 }`, where both values are computed refs. With `ReflagBootstrappedProvider`, the composable fetches opt-in metadata on first use and reports `isLoading: true` until the flags refresh succeeds or fails. +The composable does not expose fetch errors. 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 the client returned by `useClient()` and call `client.refresh()` to retry, check for an `undefined` result, and manage retry pending/error state separately. 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()` composable or the provider's loading slot for that initial loading state. diff --git a/packages/vue-sdk/src/hooks.ts b/packages/vue-sdk/src/hooks.ts index 27fc70078..4279694d6 100644 --- a/packages/vue-sdk/src/hooks.ts +++ b/packages/vue-sdk/src/hooks.ts @@ -129,7 +129,13 @@ export function useFlag(key: TKey): TypedFlags[TKey] { * * 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. + * + * 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(): UseOptInFlagsResult { const client = useClient(); @@ -160,6 +166,11 @@ export function useOptInFlags(): UseOptInFlagsResult { /** * Vue composable for setting 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/src/types.ts b/packages/vue-sdk/src/types.ts index b0430aa49..3ec8794d2 100644 --- a/packages/vue-sdk/src/types.ts +++ b/packages/vue-sdk/src/types.ts @@ -52,6 +52,13 @@ export type TypedFlags = keyof Flags extends never export type FlagKey = keyof TypedFlags; +/** + * An opt-in-enabled flag for the generated Vue 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; }; diff --git a/scripts/fix-opt-in-docs.mjs b/scripts/fix-opt-in-docs.mjs new file mode 100644 index 000000000..7c225f89e --- /dev/null +++ b/scripts/fix-opt-in-docs.mjs @@ -0,0 +1,37 @@ +import { readFileSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { pathToFileURL } from "node:url"; + +// Work around TypeDoc Markdown's lost import aliases, optional properties, +// and defaulted parameters in the opt-in reference code blocks. +export function fixOptInDocs(markdown) { + return markdown + .replaceAll( + 'type OptInFlag = Omit', + 'type OptInFlag = Omit', + ) + .replace( + /(type SetOptInOptions = \{\n\s*optedIn: boolean;\n\s*scope):/g, + "$1?:", + ) + .replace(/(type UseOptInFlagsOptions = \{\n\s*suspense):/g, "$1?:") + .replace(/### useOptInFlags\(\)[\s\S]*?(?=\n\*\*\*|$)/g, (section) => + section + .replace( + "function useOptInFlags(options: UseOptInFlagsOptions)", + "function useOptInFlags(options?: UseOptInFlagsOptions)", + ) + .replace(/`options`(?!\?)/g, "`options`?"), + ); +} + +if ( + process.argv[1] && + import.meta.url === pathToFileURL(resolve(process.argv[1])).href +) { + for (const path of process.argv.slice(2)) { + const original = readFileSync(path, "utf8"); + const updated = fixOptInDocs(original); + if (updated !== original) writeFileSync(path, updated); + } +} diff --git a/scripts/fix-opt-in-docs.test.mjs b/scripts/fix-opt-in-docs.test.mjs new file mode 100644 index 000000000..f8d944eca --- /dev/null +++ b/scripts/fix-opt-in-docs.test.mjs @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import { test } from "node:test"; + +import { fixOptInDocs } from "./fix-opt-in-docs.mjs"; + +test("qualifies the browser OptInFlag without changing unrelated types", () => { + assert.equal( + fixOptInDocs('type OptInFlag = Omit & { key: FlagKey };'), + 'type OptInFlag = Omit & { key: FlagKey };', + ); + const unrelated = 'type Other = Omit;'; + assert.equal(fixOptInDocs(unrelated), unrelated); +}); + +test("restores optional opt-in settings, not arbitrary scope properties", () => { + const input = `type SetOptInOptions = { + optedIn: boolean; + scope: "user" | "company"; +}; +type UseOptInFlagsOptions = { + suspense: boolean; +}; +type Other = { scope: string };`; + const expected = input + .replace('scope: "user"', 'scope?: "user"') + .replace("suspense:", "suspense?:"); + assert.equal(fixOptInDocs(input), expected); + assert.equal(fixOptInDocs(expected), expected); +}); + +test("marks useOptInFlags options optional in both the signature and table", () => { + const input = `### useOptInFlags() + +function useOptInFlags(options: UseOptInFlagsOptions): UseOptInFlagsResult + +\n\n\`options\`\n\n + +*** +### anotherHook() + +\`options\` +`; + const expected = input + .replace("(options:", "(options?:") + .replace("`options`", "`options`?"); + assert.equal(fixOptInDocs(input), expected); + assert.equal(fixOptInDocs(expected), expected); +}); From 9188171cdd352b9cc7aafdf1024d79eb8e21361a Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Tue, 15 Sep 2026 10:18:57 +0200 Subject: [PATCH 2/8] docs: simplify opt-in examples and guidance --- .changeset/tidy-opt-in-responses.md | 2 +- packages/browser-sdk/README.md | 20 +++------- packages/browser-sdk/src/client.ts | 3 -- packages/react-sdk/README.md | 62 +++++++++-------------------- packages/react-sdk/src/index.tsx | 6 +-- packages/vue-sdk/README.md | 45 +++++++-------------- packages/vue-sdk/src/hooks.ts | 6 +-- 7 files changed, 43 insertions(+), 101 deletions(-) diff --git a/.changeset/tidy-opt-in-responses.md b/.changeset/tidy-opt-in-responses.md index b6f294d5f..2a57085e9 100644 --- a/.changeset/tidy-opt-in-responses.md +++ b/.changeset/tidy-opt-in-responses.md @@ -2,4 +2,4 @@ "@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. +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/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index 313cd6a82..c27a389a8 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -220,9 +220,10 @@ try { 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"); + if (!response?.ok) { + console.error("Could not update opt-in"); + } } catch (error) { - // Show an error in your UI; confirmation may fail after a remote change. console.error("Could not update opt-in", error); } ``` @@ -231,16 +232,7 @@ By default, `setOptIn()` changes the opt-in for the current user, so the current 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. -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`: - -- 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. +Returns 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. @@ -248,7 +240,7 @@ For a bootstrapped client, the first `getOptInFlags()` or `getIsLoadingOptInFlag 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. +If fetching opt-in metadata fails, loading ends without exposing an error. Call `reflagClient.refresh()` to retry. ## Remote config @@ -371,7 +363,7 @@ const client = new ReflagClient({ ``` > [!NOTE] -> 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. +> Opt-in metadata refreshes use browser-visible context, even when live updates are disabled. Results may differ from bootstrapped flags evaluated with server-only context. 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 029bbf24e..a7efa180a 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -1260,9 +1260,6 @@ export class ReflagClient { * 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, diff --git a/packages/react-sdk/README.md b/packages/react-sdk/README.md index 475431e64..3d9aa4b21 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -606,7 +606,7 @@ function App({ bootstrapData }: AppProps) { > > 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. Disabling `enableLiveFlagUpdates` does not prevent the opt-in metadata refresh; only request opt-in data if browser-side re-evaluation is appropriate. +> Opt-in metadata refreshes use browser-visible context, even when live updates are disabled. Results may differ from bootstrapped flags evaluated with server-only context. ## Hooks @@ -682,7 +682,6 @@ 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 { useState } from "react"; import { type OptInFlag, useIsLoading, @@ -694,45 +693,29 @@ function OptInList() { const isProviderLoading = useIsLoading(); const { flags, isLoading } = useOptInFlags(); const setOptIn = useSetOptIn(); - const [isUpdating, setIsUpdating] = useState(false); - const [error, setError] = useState(null); - async function updateOptIn(flag: OptInFlag) { - setError(null); - setIsUpdating(true); + async function toggleOptIn(flag: OptInFlag) { 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); + 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 (isProviderLoading || isLoading) { - return

Loading opt-in flags…

; - } - - if (flags.length === 0) { - return

No opt-in flags to show. The list may also be unavailable.

; + return

Loading…

; } - return ( - <> - {flags.map((flag) => ( - - ))} - {error &&

{error}

} - - ); + return flags.map((flag) => ( + + )); } ``` @@ -740,20 +723,11 @@ 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. -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`: - -- 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. +Returns 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 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. -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. +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 f2e0b28d8..03ef258e8 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -780,10 +780,8 @@ export function useFlag( * When suspense is enabled for the provider or this hook, it suspends instead * 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. + * 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 = {}, diff --git a/packages/vue-sdk/README.md b/packages/vue-sdk/README.md index 71746a474..e3f6510dc 100644 --- a/packages/vue-sdk/README.md +++ b/packages/vue-sdk/README.md @@ -293,7 +293,7 @@ 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. Disabling `enableLiveFlagUpdates` does not prevent the opt-in metadata refresh; only request opt-in data if browser-side re-evaluation is appropriate. +> Opt-in metadata refreshes use browser-visible context, even when live updates are disabled. Results may differ from bootstrapped flags evaluated with server-only context. ## `` component @@ -399,7 +399,6 @@ Use these composables to build an end-user opt-in UI for flags where opt-in is e ```vue ``` @@ -451,20 +443,11 @@ 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. -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`: - -- An OK `Response` is returned after refreshed flag state confirms the membership change. Subscribers are notified when flags change; Vue 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. +Returns a `Response`, or `undefined` if skipped due to invalid input, offline mode etc. Check `response?.ok` for success. `useOptInFlags()` returns `{ flags, isLoading }`, where both values are computed refs. 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. -The composable does not expose fetch errors. 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 the client returned by `useClient()` and call `client.refresh()` to retry, check for an `undefined` result, and manage retry pending/error state separately. +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()` composable or the provider's loading slot for that initial loading state. diff --git a/packages/vue-sdk/src/hooks.ts b/packages/vue-sdk/src/hooks.ts index 4279694d6..97ccc9c6d 100644 --- a/packages/vue-sdk/src/hooks.ts +++ b/packages/vue-sdk/src/hooks.ts @@ -132,10 +132,8 @@ export function useFlag(key: TKey): TypedFlags[TKey] { * with the initial flags; use {@link useIsLoading} for their loading state. * Complete bootstrapped opt-in metadata needs no extra request. * - * 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. + * 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(): UseOptInFlagsResult { const client = useClient(); From 4029885156b881c66fe899672435a037d241f341 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Tue, 15 Sep 2026 10:26:59 +0200 Subject: [PATCH 3/8] docs: retain opt-in promise synchronization explanation --- packages/browser-sdk/README.md | 4 +++- packages/react-sdk/README.md | 4 +++- packages/vue-sdk/README.md | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index c27a389a8..0f6a77a1f 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -232,7 +232,9 @@ By default, `setOptIn()` changes the opt-in for the current user, so the current 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. -Returns a `Response`, or `undefined` if skipped due to invalid input, offline mode etc. Check `response?.ok` for success. +`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. diff --git a/packages/react-sdk/README.md b/packages/react-sdk/README.md index 3d9aa4b21..406596198 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -723,7 +723,9 @@ 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. -Returns a `Response`, or `undefined` if skipped due to invalid input, offline mode etc. Check `response?.ok` for success. +`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 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. diff --git a/packages/vue-sdk/README.md b/packages/vue-sdk/README.md index bfb5bbcb5..8c3d4fdd9 100644 --- a/packages/vue-sdk/README.md +++ b/packages/vue-sdk/README.md @@ -443,7 +443,9 @@ 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. -Returns a `Response`, or `undefined` if skipped due to invalid input, offline mode etc. Check `response?.ok` for success. +`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. Vue 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 }`, where both values are computed refs. 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. From 1d9735b09f68908727c28da6a09cd39fa54fb3c0 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Tue, 15 Sep 2026 10:29:01 +0200 Subject: [PATCH 4/8] docs: keep opt-in loading getter out of minimal example --- packages/browser-sdk/README.md | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index 0f6a77a1f..9558ef304 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -213,7 +213,6 @@ If a flag has end-user opt-in enabled in Reflag, you can list the opt-in options ```ts const optInFlags = reflagClient.getOptInFlags(); // [{ key, name, description, isEnabled, userOptedIn, companyOptedIn, isOptedIn }] -const isLoadingOptInFlags = reflagClient.getIsLoadingOptInFlags(); try { const response = await reflagClient.setOptIn("huddle", { From d56fa07da104fe671356fe5533e45bca9773bd3e Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Tue, 15 Sep 2026 10:30:17 +0200 Subject: [PATCH 5/8] docs: restore concise bootstrapped opt-in refresh paragraph --- packages/browser-sdk/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index 9558ef304..831dd6c15 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -340,7 +340,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 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 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 you previously used `bootstrappedFlags`, migrate like this: From f841e2ee24c6fbf6b31df8ad145bcaef8b64cb90 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Tue, 15 Sep 2026 10:31:56 +0200 Subject: [PATCH 6/8] docs: describe opt-in loading in terms of bootstrapped flags --- packages/browser-sdk/README.md | 2 +- packages/react-sdk/README.md | 4 ++-- packages/vue-sdk/README.md | 4 ++-- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index 831dd6c15..f9e67e382 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -237,7 +237,7 @@ The promise resolves to a `Response`, or `undefined` if skipped due to invalid i The `description` comes from the dedicated SDK-facing opt-in description configured in Reflag. -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. +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. diff --git a/packages/react-sdk/README.md b/packages/react-sdk/README.md index 406596198..d6c78b926 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -602,7 +602,7 @@ 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()` 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. +> 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`. > @@ -727,7 +727,7 @@ User and company opt-ins are managed independently. Setting `optedIn` to `false` 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 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`, it fetches opt-in metadata on first use and reports `isLoading: true` until that 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. diff --git a/packages/vue-sdk/README.md b/packages/vue-sdk/README.md index 8c3d4fdd9..7353a685c 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()` requests a flags refresh only if the bootstrapped state lacks opt-in metadata. It reports `isLoading: true` until that refresh settles. Complete metadata is immediately available without this extra request. +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: @@ -447,7 +447,7 @@ User and company opt-ins are managed independently. Setting `optedIn` to `false` 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 }`, where both values are computed refs. 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 }`, where both values are computed refs. With `ReflagBootstrappedProvider`, it fetches opt-in metadata on first use and reports `isLoading: true` until that 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. From 2fe5f11faf0b2a256118c3c2639ec89da6bdc61e Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Tue, 15 Sep 2026 10:34:05 +0200 Subject: [PATCH 7/8] docs: preserve live-update guidance and add opt-in refresh note --- packages/browser-sdk/README.md | 4 +++- packages/react-sdk/README.md | 4 +++- packages/vue-sdk/README.md | 4 +++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index f9e67e382..dc09ae510 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -364,7 +364,9 @@ const client = new ReflagClient({ ``` > [!NOTE] -> Opt-in metadata refreshes use browser-visible context, even when live updates are disabled. Results may differ from bootstrapped flags evaluated with server-only context. +> 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/react-sdk/README.md b/packages/react-sdk/README.md index d6c78b926..e7d61fcb0 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -606,7 +606,9 @@ function App({ bootstrapData }: AppProps) { > > If you want live flag updates to continue working after bootstrapping, use a recent `@reflag/node-sdk` so `getFlagsForBootstrap()` includes `flagStateVersion`. > -> Opt-in metadata refreshes use browser-visible context, even when live updates are disabled. Results may differ from bootstrapped flags evaluated with server-only context. +> 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 diff --git a/packages/vue-sdk/README.md b/packages/vue-sdk/README.md index 7353a685c..d01e726ff 100644 --- a/packages/vue-sdk/README.md +++ b/packages/vue-sdk/README.md @@ -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] -> Opt-in metadata refreshes use browser-visible context, even when live updates are disabled. Results may differ from bootstrapped flags evaluated with server-only context. +> 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 From cd88261c7d8797d35d75da62de11d494ea05fd2c Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Tue, 15 Sep 2026 10:34:55 +0200 Subject: [PATCH 8/8] docs: remove bootstrapped opt-in metadata availability claims --- packages/react-sdk/src/index.tsx | 1 - packages/vue-sdk/src/hooks.ts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index 03ef258e8..1c41a57a7 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -776,7 +776,6 @@ 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; 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. A Suspense boundary alone does not enable it. * diff --git a/packages/vue-sdk/src/hooks.ts b/packages/vue-sdk/src/hooks.ts index 97ccc9c6d..93eb047eb 100644 --- a/packages/vue-sdk/src/hooks.ts +++ b/packages/vue-sdk/src/hooks.ts @@ -130,7 +130,6 @@ export function useFlag(key: TKey): TypedFlags[TKey] { * 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; use {@link useIsLoading} for their loading state. - * Complete bootstrapped opt-in metadata needs no extra request. * * If fetching opt-in metadata fails, loading ends without exposing an error. * Call `refresh()` on the client returned by {@link useClient} to retry.