From 7d6d2df6b8c45856150721f0027be733cc375202 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Fri, 11 Sep 2026 14:59:20 +0200 Subject: [PATCH] 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); +});