From aaa0f3c2c5eac0041f848b1bf56e0b2d8a69b512 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 29 Jul 2026 21:31:20 +0200 Subject: [PATCH 1/6] feat: add end-user opt-in APIs --- .changeset/end-user-opt-in-sdk.md | 7 + packages/browser-sdk/README.md | 19 ++ packages/browser-sdk/src/client.ts | 143 ++++++++ packages/browser-sdk/src/flag/flagCache.ts | 38 ++- packages/browser-sdk/src/flag/flags.ts | 49 +++ packages/browser-sdk/src/index.ts | 3 + packages/browser-sdk/test/client.test.ts | 367 +++++++++++++++++++++ packages/browser-sdk/test/flags.test.ts | 53 ++- packages/react-sdk/README.md | 24 ++ packages/react-sdk/src/index.tsx | 31 ++ packages/react-sdk/test/usage.test.tsx | 106 ++++++ packages/vue-sdk/README.md | 25 ++ packages/vue-sdk/src/hooks.ts | 31 ++ packages/vue-sdk/src/index.ts | 4 + packages/vue-sdk/test/usage.test.ts | 69 ++++ 15 files changed, 966 insertions(+), 3 deletions(-) create mode 100644 .changeset/end-user-opt-in-sdk.md diff --git a/.changeset/end-user-opt-in-sdk.md b/.changeset/end-user-opt-in-sdk.md new file mode 100644 index 000000000..b37cfc86b --- /dev/null +++ b/.changeset/end-user-opt-in-sdk.md @@ -0,0 +1,7 @@ +--- +"@reflag/browser-sdk": minor +"@reflag/react-sdk": minor +"@reflag/vue-sdk": minor +--- + +Add end-user opt-in helpers for listing opt-in-enabled flags and setting whether the current user or company has opted into a flag. diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index 7238e822c..2111c59dc 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -206,6 +206,25 @@ by down-stream clients, like the React SDK. Note that accessing `isEnabled` on the object returned by `getFlags` does not automatically generate a `check` event, contrary to the `isEnabled` property on the object returned by `getFlag`. +## End-user opt-in + +If a flag has end-user opt-in enabled in Reflag, you can list the opt-in options for the current context and set or cancel opt-in for the current user or company. + +```ts +const optInFlags = reflagClient.getOptInFlags(); +// [{ key, name, description, isEnabled, userOptedIn, companyOptedIn, isOptedIn }] + +await reflagClient.setOptIn("huddle", { optedIn: true }); +await reflagClient.setOptIn("huddle", { optedIn: false }); + +await reflagClient.setOptIn("huddle", { + optedIn: true, + scope: "company", +}); +``` + +`scope` defaults to `"user"`. User scope requires a current `user.id`; company scope requires a current `company.id`. Setting `optedIn` to `false` cancels only the selected scope's opt-in, so cancelling user opt-in does not cancel matching company opt-in, and vice versa. After a successful mutation response, `setOptIn()` waits for the returned flag-state version, applies the refreshed flags locally, and synchronously notifies `flagsUpdated` listeners before its promise resolves. It rejects if the updated scoped membership cannot be confirmed in the SDK; the remote mutation may already have succeeded, so retrying the idempotent setter is safe. The `description` comes from the dedicated SDK-facing opt-in description configured in Reflag. + ## Remote config Remote config is a dynamic and flexible approach to configuring flag behavior outside of your app – without needing to re-deploy it. diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index d6d813d5c..1f970988f 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -16,6 +16,7 @@ import { CheckEvent, FallbackFlagOverride, FlagsClient, + OptInFlag, RawFlags, } from "./flag/flags"; import { isValidFlagStateVersion } from "./flag/flagStateVersion"; @@ -419,6 +420,18 @@ export type FlagRemoteConfig = /** * Represents a flag. */ +export type SetOptInOptions = { + /** + * Whether the scoped subject has opted in. + */ + optedIn: boolean; + + /** + * Whether to update the current user or current company. Defaults to `user`. + */ + scope?: "user" | "company"; +}; + export interface Flag { /** * Result of flag flag evaluation. @@ -1192,6 +1205,136 @@ export class ReflagClient { return this.flagsClient.refreshFlags(); } + /** + * Returns opt-in-enabled flags for the current context. + */ + getOptInFlags(): OptInFlag[] { + return Object.values(this.getFlags()).flatMap((flag) => { + if (flag.optInEnabled !== true || !flag.optIn) return []; + + return { + key: flag.key, + name: flag.optIn.name, + description: flag.optIn.description, + isEnabled: flag.isEnabledOverride ?? flag.isEnabled, + userOptedIn: flag.optIn.userOptedIn, + companyOptedIn: flag.optIn.companyOptedIn, + isOptedIn: flag.optIn.isOptedIn, + } satisfies OptInFlag; + }); + } + + /** + * Set whether the current user or company has opted into a flag. + */ + async setOptIn( + flagKey: string, + options: SetOptInOptions, + ): Promise { + if (this.config.offline) { + return; + } + + if (typeof flagKey !== "string" || !flagKey) { + this.logger.error("`setOptIn` call ignored. No `flagKey` provided"); + return; + } + + if (!options || typeof options.optedIn !== "boolean") { + this.logger.error("`setOptIn` call ignored. `optedIn` must be a boolean"); + return; + } + + const scope = options.scope ?? "user"; + if (scope !== "user" && scope !== "company") { + this.logger.error( + '`setOptIn` call ignored. `scope` must be "user" or "company"', + ); + return; + } + + const scopedContext = this.context[scope]; + if (!scopedContext?.id) { + this.logger.error( + `\`setOptIn\` call ignored. No \`${scope}\` context provided`, + ); + return; + } + + const context = { + user: this.context.user + ? { ...this.context.user, id: String(this.context.user.id) } + : undefined, + company: this.context.company + ? { ...this.context.company, id: String(this.context.company.id) } + : undefined, + other: this.context.other, + }; + + const res = await this.httpClient.post({ + path: "/flags/opt-in", + body: { + key: flagKey, + optedIn: options.optedIn, + scope, + context, + }, + }); + + if (!res.ok) { + await logResponseError({ + logger: this.logger, + res, + message: "set opt-in request failed", + extra: { flagKey, optedIn: options.optedIn, scope }, + }); + return res; + } + + let flagStateVersion: number; + try { + const body = await res.clone().json(); + if (!isValidFlagStateVersion(body?.flagStateVersion)) { + throw new Error("Response did not include a valid flag state version"); + } + flagStateVersion = body.flagStateVersion; + } catch (error) { + this.logger.error( + "set opt-in succeeded but its flag state version could not be read", + error, + ); + throw error; + } + + const refreshedFlags = + await this.flagsClient.refreshFlags(flagStateVersion); + if (!refreshedFlags) { + const error = new Error( + "Opt-in changed remotely, but the updated flag state could not be confirmed", + ); + this.logger.error("set opt-in confirmation failed", error); + throw error; + } + + const refreshedOptIn = refreshedFlags[flagKey]?.optIn; + const scopedOptIn = + scope === "user" + ? refreshedOptIn?.userOptedIn + : refreshedOptIn?.companyOptedIn; + const isConfirmed = options.optedIn + ? scopedOptIn === true + : scopedOptIn !== true; + if (!isConfirmed) { + const error = new Error( + `Opt-in changed remotely, but the updated ${scope} membership was not reflected in the SDK`, + ); + this.logger.error("set opt-in confirmation failed", error); + throw error; + } + + return res; + } + /** * @deprecated Use `getFlag` instead. */ diff --git a/packages/browser-sdk/src/flag/flagCache.ts b/packages/browser-sdk/src/flag/flagCache.ts index 4e2baa3b9..6b6fff54c 100644 --- a/packages/browser-sdk/src/flag/flagCache.ts +++ b/packages/browser-sdk/src/flag/flagCache.ts @@ -1,9 +1,33 @@ import { StorageAdapter } from "../storage"; -import { RawFlags } from "./flags"; +import { RawFlagOptIn, RawFlags } from "./flags"; import { isValidFlagStateVersion } from "./flagStateVersion"; const DEFAULT_STORAGE_KEY = "__reflag_fetched_flags"; +function parseOptIn(optIn: any): RawFlagOptIn | null | undefined { + if (typeof optIn === "undefined") return undefined; + if (optIn === null) return null; + if (!isObject(optIn)) return; + + if ( + typeof optIn.userOptedIn !== "boolean" || + typeof optIn.companyOptedIn !== "boolean" || + typeof optIn.isOptedIn !== "boolean" || + typeof optIn.name !== "string" || + !(typeof optIn.description === "string" || optIn.description === null) + ) { + return; + } + + return { + userOptedIn: optIn.userOptedIn, + companyOptedIn: optIn.companyOptedIn, + isOptedIn: optIn.isOptedIn, + name: optIn.name, + description: optIn.description, + }; +} + interface cacheEntry { expireAt: number; staleAt: number; @@ -21,6 +45,8 @@ export function parseAPIFlagsResponse(flagsInput: any): RawFlags | undefined { for (const key in flagsInput) { const flag = flagsInput[key]; + const optIn = parseOptIn(flag.optIn); + if ( typeof flag.isEnabled !== "boolean" || flag.key !== key || @@ -28,7 +54,11 @@ export function parseAPIFlagsResponse(flagsInput: any): RawFlags | undefined { (flag.config && typeof flag.config !== "object") || (flag.missingContextFields && !Array.isArray(flag.missingContextFields)) || - (flag.ruleEvaluationResults && !Array.isArray(flag.ruleEvaluationResults)) + (flag.ruleEvaluationResults && + !Array.isArray(flag.ruleEvaluationResults)) || + (typeof flag.optInEnabled !== "undefined" && + typeof flag.optInEnabled !== "boolean") || + (typeof flag.optIn !== "undefined" && typeof optIn === "undefined") ) { return; } @@ -40,6 +70,10 @@ export function parseAPIFlagsResponse(flagsInput: any): RawFlags | undefined { config: flag.config, missingContextFields: flag.missingContextFields, ruleEvaluationResults: flag.ruleEvaluationResults, + ...(typeof flag.optInEnabled !== "undefined" && { + optInEnabled: flag.optInEnabled, + }), + ...(typeof flag.optIn !== "undefined" && { optIn }), }; } diff --git a/packages/browser-sdk/src/flag/flags.ts b/packages/browser-sdk/src/flag/flags.ts index 1cfc19f53..aa2c0812c 100644 --- a/packages/browser-sdk/src/flag/flags.ts +++ b/packages/browser-sdk/src/flag/flags.ts @@ -16,6 +16,45 @@ import { isValidFlagStateVersion } from "./flagStateVersion"; const INITIAL_FETCH_RETRY_DELAYS_MS = [0, 5000]; +export type RawFlagOptIn = { + /** + * Whether the current user has opted into the flag. + */ + userOptedIn: boolean; + + /** + * Whether the current company has opted into the flag. + */ + companyOptedIn: boolean; + + /** + * Whether either the current user or company has opted into the flag. + */ + isOptedIn: boolean; + + /** + * Display name of the opt-in flag. + */ + name: string; + + /** + * SDK-facing opt-in description. + */ + description: string | null; +}; + +export type OptInFlag = RawFlagOptIn & { + /** + * Flag key. + */ + key: string; + + /** + * Result of flag evaluation. + */ + isEnabled: boolean; +}; + /** * A flag fetched from the server. */ @@ -51,6 +90,16 @@ export type RawFlag = { */ missingContextFields?: string[]; + /** + * Whether end-user opt-in is enabled for this flag. + */ + optInEnabled?: boolean; + + /** + * Opt-in metadata for this flag and the current context. + */ + optIn?: RawFlagOptIn | null; + /** * Optional user-defined dynamic configuration. */ diff --git a/packages/browser-sdk/src/index.ts b/packages/browser-sdk/src/index.ts index 269e1338d..095fa8237 100644 --- a/packages/browser-sdk/src/index.ts +++ b/packages/browser-sdk/src/index.ts @@ -4,6 +4,7 @@ export type { Flag, FlagRemoteConfig, InitOptions, + SetOptInOptions, ToolbarOptions, } from "./client"; export { ReflagClient } from "./client"; @@ -38,7 +39,9 @@ export type { CheckEvent, FallbackFlagOverride, FlagOverrides, + OptInFlag, RawFlag, + RawFlagOptIn, RawFlags, } from "./flag/flags"; export type { HookArgs, State, TrackEvent } from "./hooksManager"; diff --git a/packages/browser-sdk/test/client.test.ts b/packages/browser-sdk/test/client.test.ts index e8896fb20..25c8099d4 100644 --- a/packages/browser-sdk/test/client.test.ts +++ b/packages/browser-sdk/test/client.test.ts @@ -236,6 +236,373 @@ describe("ReflagClient", () => { }); }); + describe("opt-in", () => { + it("lists opt-in-enabled flags for the current context", async () => { + client = new ReflagClient({ + publishableKey: "test-key-opt-in-list", + user: { id: "user1" }, + bootstrappedFlags: { + optInFlag: { + key: "optInFlag", + isEnabled: false, + targetingVersion: 1, + optInEnabled: true, + optIn: { + userOptedIn: false, + companyOptedIn: true, + isOptedIn: true, + name: "Opt-in flag", + description: "Try it early", + }, + }, + notOptInFlag: { + key: "notOptInFlag", + isEnabled: true, + targetingVersion: 2, + optInEnabled: false, + optIn: null, + }, + hardOffFlag: { + key: "hardOffFlag", + isEnabled: false, + targetingVersion: 3, + optInEnabled: true, + optIn: null, + }, + }, + }); + await client.initialize(); + + expect(client.getOptInFlags()).toEqual([ + { + key: "optInFlag", + name: "Opt-in flag", + description: "Try it early", + isEnabled: false, + userOptedIn: false, + companyOptedIn: true, + isOptedIn: true, + }, + ]); + }); + + it("posts opt-in requests and refreshes flags at the returned state version", async () => { + const flagsUpdated = vi.fn(); + const requests: string[] = []; + + server.use( + http.post( + "https://front.reflag.com/flags/opt-in", + async ({ request }) => { + requests.push("set-opt-in"); + expect(await request.json()).toMatchObject({ + key: "optInFlag", + optedIn: true, + scope: "user", + context: { + user: { id: "user1" }, + company: { id: "company1" }, + }, + }); + + return HttpResponse.json({ success: true, flagStateVersion: 7 }); + }, + ), + http.get( + "https://front.reflag.com/features/evaluated", + ({ request }) => { + requests.push("flags"); + const url = new URL(request.url); + expect(url.searchParams.get("waitForVersion")).toBe("7"); + + return HttpResponse.json({ + success: true, + flagStateVersion: 7, + features: { + optInFlag: { + key: "optInFlag", + isEnabled: true, + targetingVersion: 2, + optInEnabled: true, + optIn: { + userOptedIn: true, + companyOptedIn: false, + isOptedIn: true, + name: "Opt-in flag", + description: "Try it early", + }, + }, + }, + }); + }, + ), + ); + + client = new ReflagClient({ + publishableKey: "test-key-opt-in", + user: { id: "user1" }, + company: { id: "company1" }, + bootstrappedFlags: { + optInFlag: { + key: "optInFlag", + isEnabled: false, + targetingVersion: 1, + optInEnabled: true, + optIn: { + userOptedIn: false, + companyOptedIn: false, + isOptedIn: false, + name: "Opt-in flag", + description: "Try it early", + }, + }, + }, + }); + client.on("flagsUpdated", flagsUpdated); + await client.initialize(); + flagsUpdated.mockClear(); + + const response = await client.setOptIn("optInFlag", { optedIn: true }); + + expect(response?.ok).toBe(true); + await expect(response!.json()).resolves.toEqual({ + success: true, + flagStateVersion: 7, + }); + expect(requests).toEqual(["set-opt-in", "flags"]); + expect(client.getOptInFlags()).toEqual([ + { + key: "optInFlag", + name: "Opt-in flag", + description: "Try it early", + isEnabled: true, + userOptedIn: true, + companyOptedIn: false, + isOptedIn: true, + }, + ]); + expect(flagsUpdated).toHaveBeenCalledTimes(1); + }); + + it("cancels opt-in and refreshes flags at the returned state version", async () => { + const requests: string[] = []; + + server.use( + http.post( + "https://front.reflag.com/flags/opt-in", + async ({ request }) => { + requests.push("cancel-opt-in"); + expect(await request.json()).toMatchObject({ + key: "optInFlag", + optedIn: false, + scope: "user", + context: { + user: { id: "user1" }, + company: { id: "company1" }, + }, + }); + + return HttpResponse.json({ success: true, flagStateVersion: 8 }); + }, + ), + http.get( + "https://front.reflag.com/features/evaluated", + ({ request }) => { + requests.push("flags"); + expect( + new URL(request.url).searchParams.get("waitForVersion"), + ).toBe("8"); + + return HttpResponse.json({ + success: true, + flagStateVersion: 8, + features: { + optInFlag: { + key: "optInFlag", + isEnabled: false, + targetingVersion: 3, + optInEnabled: true, + optIn: { + userOptedIn: false, + companyOptedIn: false, + isOptedIn: false, + name: "Opt-in flag", + description: "Try it early", + }, + }, + }, + }); + }, + ), + ); + + client = new ReflagClient({ + publishableKey: "test-key-cancel-opt-in", + user: { id: "user1" }, + company: { id: "company1" }, + bootstrappedFlags: { + optInFlag: { + key: "optInFlag", + isEnabled: true, + targetingVersion: 2, + optInEnabled: true, + optIn: { + userOptedIn: true, + companyOptedIn: false, + isOptedIn: true, + name: "Opt-in flag", + description: "Try it early", + }, + }, + }, + }); + await client.initialize(); + + const response = await client.setOptIn("optInFlag", { optedIn: false }); + + expect(response?.ok).toBe(true); + expect(requests).toEqual(["cancel-opt-in", "flags"]); + expect(client.getOptInFlags()[0]).toMatchObject({ + key: "optInFlag", + isEnabled: false, + userOptedIn: false, + isOptedIn: false, + }); + }); + + it("rejects when the refreshed SDK state does not confirm the membership change", async () => { + server.use( + http.post("https://front.reflag.com/flags/opt-in", () => + HttpResponse.json({ success: true, flagStateVersion: 9 }), + ), + http.get("https://front.reflag.com/features/evaluated", () => + HttpResponse.json({ + success: true, + flagStateVersion: 9, + features: { + optInFlag: { + key: "optInFlag", + isEnabled: false, + targetingVersion: 1, + optInEnabled: true, + optIn: { + userOptedIn: false, + companyOptedIn: false, + isOptedIn: false, + name: "Opt-in flag", + description: null, + }, + }, + }, + }), + ), + ); + + client = new ReflagClient({ + publishableKey: "test-key-unconfirmed-opt-in", + user: { id: "user1" }, + bootstrappedFlags: { + optInFlag: { + key: "optInFlag", + isEnabled: false, + targetingVersion: 1, + optInEnabled: true, + optIn: { + userOptedIn: false, + companyOptedIn: false, + isOptedIn: false, + name: "Opt-in flag", + description: null, + }, + }, + }, + }); + await client.initialize(); + + await expect( + client.setOptIn("optInFlag", { optedIn: true }), + ).rejects.toThrow( + "the updated user membership was not reflected in the SDK", + ); + }); + + it("validates the default user identity before changing opt-in", async () => { + client = new ReflagClient({ + publishableKey: "test-key-opt-in-no-user", + company: { id: "company1" }, + bootstrappedFlags: {}, + }); + await client.initialize(); + + const response = await client.setOptIn("optInFlag", { optedIn: true }); + + expect(response).toBeUndefined(); + const optInCalls = vi + .mocked(httpClientPost) + .mock.calls.filter( + ([request]) => + (request as { path?: string }).path === "/flags/opt-in", + ); + expect(optInCalls).toHaveLength(0); + }); + + it("serializes context ids as strings", async () => { + server.use( + http.post( + "https://front.reflag.com/flags/opt-in", + async ({ request }) => { + expect(await request.json()).toMatchObject({ + key: "optInFlag", + optedIn: true, + scope: "company", + context: { + user: { id: "123" }, + company: { id: "456" }, + }, + }); + return HttpResponse.json({ success: true, flagStateVersion: 10 }); + }, + ), + http.get("https://front.reflag.com/features/evaluated", () => + HttpResponse.json({ + success: true, + flagStateVersion: 10, + features: { + optInFlag: { + key: "optInFlag", + isEnabled: true, + targetingVersion: 1, + optInEnabled: true, + optIn: { + userOptedIn: false, + companyOptedIn: true, + isOptedIn: true, + name: "Opt-in flag", + description: null, + }, + }, + }, + }), + ), + ); + + client = new ReflagClient({ + publishableKey: "test-key-opt-in-number-ids", + user: { id: 123 }, + company: { id: 456 }, + bootstrappedFlags: {}, + }); + await client.initialize(); + + const response = await client.setOptIn("optInFlag", { + optedIn: true, + scope: "company", + }); + + expect(response?.ok).toBe(true); + }); + }); + describe("track", () => { it("sends events directly and returns the delivery response", async () => { const response = await client.track("test-event", { a: 1 }); diff --git a/packages/browser-sdk/test/flags.test.ts b/packages/browser-sdk/test/flags.test.ts index 233772c7d..822e15177 100644 --- a/packages/browser-sdk/test/flags.test.ts +++ b/packages/browser-sdk/test/flags.test.ts @@ -2,7 +2,12 @@ import { afterAll, beforeEach, describe, expect, test, vi } from "vitest"; import { version } from "../package.json"; import { FLAGS_EXPIRE_MS } from "../src/config"; -import { FlagsClient, FetchedFlagsResult, RawFlag } from "../src/flag/flags"; +import { + FlagsClient, + FetchedFlagsResult, + RawFlag, + validateFlagsResponse, +} from "../src/flag/flags"; import { HttpClient } from "../src/httpClient"; import { newCache, TEST_STALE_MS } from "./flagCache.test"; import { flagResponse, flagsResult } from "./mocks/handlers"; @@ -62,6 +67,52 @@ describe("FlagsClient", () => { vi.clearAllMocks(); }); + test("parses opt-in metadata", () => { + const result = validateFlagsResponse({ + success: true, + features: { + optInFlag: { + key: "optInFlag", + isEnabled: true, + targetingVersion: 1, + optInEnabled: true, + optIn: { + userOptedIn: true, + companyOptedIn: false, + isOptedIn: true, + name: "Opt-in flag", + description: "Try it early", + }, + }, + normalFlag: { + key: "normalFlag", + isEnabled: false, + targetingVersion: 2, + optInEnabled: false, + optIn: null, + }, + }, + }); + + expect(result?.flags.optInFlag).toMatchObject({ + key: "optInFlag", + isEnabled: true, + optInEnabled: true, + optIn: { + userOptedIn: true, + companyOptedIn: false, + isOptedIn: true, + name: "Opt-in flag", + description: "Try it early", + }, + }); + expect(result?.flags.normalFlag).toMatchObject({ + key: "normalFlag", + optInEnabled: false, + optIn: null, + }); + }); + test("fetches flags", async () => { const { newFlagsClient, httpClient } = flagsClientFactory(); const flagsClient = newFlagsClient(); diff --git a/packages/react-sdk/README.md b/packages/react-sdk/README.md index 1860305d8..b049e3118 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -654,6 +654,30 @@ function StartHuddleButton() { } ``` +### `useOptInFlags()` and `useSetOptIn()` + +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"; + +function OptInList() { + const optInFlags = useOptInFlags(); + const setOptIn = useSetOptIn(); + + return optInFlags.map((flag) => ( + + )); +} +``` + +`scope` defaults to `"user"`. User scope requires a current `user.id`; company scope requires a current `company.id`. Setting `optedIn` to `false` cancels only the selected scope's opt-in. For successful mutation responses, the returned promise resolves only after the Browser SDK has applied and confirmed the refreshed membership state and notified `useOptInFlags()`. React commits the resulting render using its normal scheduling. + ### `useTrack()` `useTrack()` lets you send custom events to Reflag. Use this whenever a user _uses_ a feature. These events can be used to analyze feature usage in Reflag. diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index 661ac91b8..2f0a6bec3 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -18,11 +18,13 @@ import { HookArgs, InitOptions, Logger, + OptInFlag, RawFlag, RawFlags as BrowserRawFlags, ReflagClient, ReflagContext, RequestFeedbackData, + SetOptInOptions, StorageAdapter, TrackEvent, UnassignedFeedback, @@ -37,6 +39,8 @@ const useIsomorphicLayoutEffect = export type { CheckEvent, CompanyContext, + OptInFlag, + SetOptInOptions, StorageAdapter, TrackEvent, UserContext, @@ -523,6 +527,33 @@ export function useFlag(key: TKey): TypedFlags[TKey] { }; } +/** + * Returns opt-in-enabled flags for the current context. + */ +export function useOptInFlags(): OptInFlag[] { + const client = useClient(); + const [optInFlags, setOptInFlags] = useState(client.getOptInFlags()); + + useOnEvent( + "flagsUpdated", + () => { + setOptInFlags(client.getOptInFlags()); + }, + client, + ); + + return optInFlags; +} + +/** + * Returns a function to set whether the current user or company has opted into a flag. + */ +export function useSetOptIn() { + const client = useClient(); + return (key: FlagKey, options: SetOptInOptions) => + client.setOptIn(String(key), options); +} + /** * Returns a function to send an event when a user performs an action * Note: When calling `useTrack`, user/company must already be set. diff --git a/packages/react-sdk/test/usage.test.tsx b/packages/react-sdk/test/usage.test.tsx index 3a7c87d91..69403469b 100644 --- a/packages/react-sdk/test/usage.test.tsx +++ b/packages/react-sdk/test/usage.test.tsx @@ -27,7 +27,9 @@ import { useFlag, useIsLoading, useOnEvent, + useOptInFlags, useRequestFeedback, + useSetOptIn, useSendFeedback, useTrack, useUpdateCompany, @@ -888,6 +890,110 @@ describe("useFlag with ReflagBootstrappedProvider", () => { // because ReflagBootstrappedProvider requires flags to be provided }); +describe("opt-in hooks", () => { + test("useOptInFlags returns and updates opt-in flags", async () => { + const bootstrapFlags: BootstrappedFlags = { + context: { user, company, other }, + flags: { + optInFlag: { + key: "optInFlag", + isEnabled: false, + targetingVersion: 1, + optInEnabled: true, + optIn: { + userOptedIn: false, + companyOptedIn: false, + isOptedIn: false, + name: "Opt-in flag", + description: "Try it early", + }, + }, + notOptInFlag: { + key: "notOptInFlag", + isEnabled: true, + targetingVersion: 2, + optInEnabled: false, + optIn: null, + }, + }, + }; + + const { result, unmount } = renderHook( + () => ({ client: useClient(), optInFlags: useOptInFlags() }), + { + wrapper: ({ children }) => + getBootstrapProvider(bootstrapFlags, { children }), + }, + ); + + await waitFor(() => { + expect(result.current.optInFlags).toEqual([ + { + key: "optInFlag", + name: "Opt-in flag", + description: "Try it early", + isEnabled: false, + userOptedIn: false, + companyOptedIn: false, + isOptedIn: false, + }, + ]); + }); + + act(() => { + result.current.client.updateFlags({ + optInFlag: { + key: "optInFlag", + isEnabled: true, + targetingVersion: 2, + optInEnabled: true, + optIn: { + userOptedIn: true, + companyOptedIn: false, + isOptedIn: true, + name: "Opt-in flag", + description: "Try it early", + }, + }, + }); + }); + + await waitFor(() => { + expect(result.current.optInFlags).toEqual([ + { + key: "optInFlag", + name: "Opt-in flag", + description: "Try it early", + isEnabled: true, + userOptedIn: true, + companyOptedIn: false, + isOptedIn: true, + }, + ]); + }); + + unmount(); + }); + + test("useSetOptIn delegates to the browser client", async () => { + const setOptIn = vi + .spyOn(ReflagClient.prototype, "setOptIn") + .mockResolvedValue(undefined); + + const { result, unmount } = renderHook(() => useSetOptIn(), { + wrapper: ({ children }) => getProvider({ children }), + }); + + await act(async () => { + await result.current("abc", { optedIn: false }); + }); + + expect(setOptIn).toHaveBeenCalledWith("abc", { optedIn: false }); + setOptIn.mockRestore(); + unmount(); + }); +}); + describe("", () => { test("renders with external client and optional loadingComponent", async () => { const client = new ReflagClient({ diff --git a/packages/vue-sdk/README.md b/packages/vue-sdk/README.md index 92aba0d9a..c000d1f41 100644 --- a/packages/vue-sdk/README.md +++ b/packages/vue-sdk/README.md @@ -391,6 +391,31 @@ const { isEnabled, track, requestFeedback, config } = useFlag("huddles"); See the reference docs for details. +### `useOptInFlags()` and `useSetOptIn()` + +Use these composables to build an end-user opt-in UI for flags where opt-in is enabled in Reflag. + +```vue + + + +``` + +`scope` defaults to `"user"`. User scope requires a current `user.id`; company scope requires a current `company.id`. Setting `optedIn` to `false` cancels only the selected scope's opt-in. For successful mutation responses, the returned promise resolves only after the Browser SDK has applied and confirmed the refreshed membership state and notified `useOptInFlags()`. Vue commits the resulting render using its normal scheduling. + ### `useTrack()` `useTrack()` returns a function which lets you send custom events to Reflag. It takes a string argument with the event name and optionally an object with properties to attach the event. diff --git a/packages/vue-sdk/src/hooks.ts b/packages/vue-sdk/src/hooks.ts index 40be04fb4..3c77c486a 100644 --- a/packages/vue-sdk/src/hooks.ts +++ b/packages/vue-sdk/src/hooks.ts @@ -10,8 +10,10 @@ import { import { HookArgs, InitOptions, + OptInFlag, ReflagClient, RequestFeedbackData, + SetOptInOptions, UnassignedFeedback, } from "@reflag/browser-sdk"; @@ -120,6 +122,35 @@ export function useFlag(key: TKey): TypedFlags[TKey] { } as TypedFlags[TKey]; } +/** + * Vue composable for getting opt-in-enabled flags for the current context. + */ +export function useOptInFlags() { + const client = useClient(); + const optInFlags = ref(client.getOptInFlags()); + + const updateOptInFlags = () => { + optInFlags.value = client.getOptInFlags(); + }; + + onMounted(() => { + updateOptInFlags(); + }); + + useOnEvent("flagsUpdated", updateOptInFlags, client); + + return computed(() => optInFlags.value); +} + +/** + * Vue composable for setting whether the current user or company has opted into a flag. + */ +export function useSetOptIn() { + const client = useClient(); + return (key: FlagKey, options: SetOptInOptions) => + client.setOptIn(String(key), options); +} + /** * Vue composable for tracking custom events. * diff --git a/packages/vue-sdk/src/index.ts b/packages/vue-sdk/src/index.ts index 7b2ab5f02..bff708a08 100644 --- a/packages/vue-sdk/src/index.ts +++ b/packages/vue-sdk/src/index.ts @@ -9,7 +9,9 @@ export { useFlag, useIsLoading, useOnEvent, + useOptInFlags, useRequestFeedback, + useSetOptIn, useSendFeedback, useTrack, useUpdateCompany, @@ -33,6 +35,8 @@ export type { export type { CheckEvent, CompanyContext, + OptInFlag, + SetOptInOptions, TrackEvent, UserContext, } from "@reflag/browser-sdk"; diff --git a/packages/vue-sdk/test/usage.test.ts b/packages/vue-sdk/test/usage.test.ts index 5872246bc..6ca74bc64 100644 --- a/packages/vue-sdk/test/usage.test.ts +++ b/packages/vue-sdk/test/usage.test.ts @@ -9,6 +9,8 @@ import { ReflagProvider, useClient, useFlag, + useOptInFlags, + useSetOptIn, } from "../src"; // Mock ReflagClient prototype methods like the React SDK tests @@ -24,6 +26,8 @@ beforeAll(() => { isEnabledOverride: null, }); vi.spyOn(ReflagClient.prototype, "getFlags").mockReturnValue({}); + vi.spyOn(ReflagClient.prototype, "getOptInFlags").mockReturnValue([]); + vi.spyOn(ReflagClient.prototype, "setOptIn").mockResolvedValue(undefined); vi.spyOn(ReflagClient.prototype, "on").mockReturnValue(() => { // cleanup function }); @@ -83,6 +87,71 @@ describe("ReflagProvider", () => { ).toBe(true); }); + test("useOptInFlags returns opt-in flags and subscribes to updates", async () => { + const optInFlags = [ + { + key: "optInFlag", + name: "Opt-in flag", + description: "Try it early", + isEnabled: false, + userOptedIn: false, + companyOptedIn: false, + isOptedIn: false, + }, + ]; + vi.mocked(ReflagClient.prototype.getOptInFlags).mockReturnValue(optInFlags); + + const Child = defineComponent({ + setup() { + const flags = useOptInFlags(); + return { flags }; + }, + template: "
", + }); + + const wrapper = mount(ReflagProvider, { + props: { + publishableKey: "key-opt-in-flags", + }, + slots: { default: () => h(Child) }, + }); + + await nextTick(); + expect(wrapper.findComponent(Child).vm.flags).toEqual(optInFlags); + expect(ReflagClient.prototype.on).toHaveBeenCalledWith( + "flagsUpdated", + expect.any(Function), + ); + }); + + test("useSetOptIn delegates to the browser client", async () => { + const Child = defineComponent({ + setup() { + const setOptIn = useSetOptIn(); + return { setOptIn }; + }, + template: "
", + }); + + const wrapper = mount(ReflagProvider, { + props: { + publishableKey: "key-set-opt-in", + }, + slots: { default: () => h(Child) }, + }); + + await nextTick(); + await wrapper.findComponent(Child).vm.setOptIn("optInFlag", { + optedIn: false, + scope: "company", + }); + + expect(ReflagClient.prototype.setOptIn).toHaveBeenCalledWith("optInFlag", { + optedIn: false, + scope: "company", + }); + }); + test("allows disabling live flag updates explicitly", async () => { const Child = defineComponent({ setup() { From 70bfebe50d38635e1b126bc6479c624178183270 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 30 Jul 2026 10:58:18 +0200 Subject: [PATCH 2/6] fix: guard opt-in refresh races --- packages/browser-sdk/src/client.ts | 52 +++++-- packages/browser-sdk/src/flag/flags.ts | 21 ++- packages/browser-sdk/test/client.test.ts | 165 +++++++++++++++++++++++ packages/browser-sdk/test/deferred.ts | 8 ++ packages/browser-sdk/test/flags.test.ts | 78 ++++++++++- 5 files changed, 310 insertions(+), 14 deletions(-) create mode 100644 packages/browser-sdk/test/deferred.ts diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 1f970988f..b848d15af 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -483,6 +483,7 @@ function shouldShowToolbar(opts: InitOptions) { */ export class ReflagClient { private state: State = "idle"; + private contextUpdateLoading = false; private readonly publishableKey: string; private context: ReflagContext; private config: Config; @@ -946,12 +947,13 @@ export class ReflagClient { const shouldTrackLoading = this.state === "initialized"; if (shouldTrackLoading) { + this.contextUpdateLoading = true; this.setState("initializing"); } const didApply = await this.flagsClient.setContext(this.context); - if (didApply && this.state === "initializing") { - this.setState("initialized"); + if (didApply) { + this.finishContextUpdate(); } } @@ -1005,6 +1007,10 @@ export class ReflagClient { ); } + if (contextChanged) { + this.finishContextUpdate(); + } + if (!contextChanged) { return; } @@ -1261,6 +1267,27 @@ export class ReflagClient { return; } + const failConfirmation = (message: string): never => { + const error = new Error(message); + this.logger.error("set opt-in confirmation failed", error); + throw error; + }; + + const scopedContextId = String(scopedContext.id); + const assertScopedContextUnchanged = () => { + const currentScopedContext = this.context[scope]; + if ( + currentScopedContext?.id && + String(currentScopedContext.id) === scopedContextId + ) { + return; + } + + failConfirmation( + `Opt-in changed remotely, but the ${scope} context changed before the updated state could be confirmed`, + ); + }; + const context = { user: this.context.user ? { ...this.context.user, id: String(this.context.user.id) } @@ -1306,17 +1333,17 @@ export class ReflagClient { throw error; } + assertScopedContextUnchanged(); const refreshedFlags = await this.flagsClient.refreshFlags(flagStateVersion); + assertScopedContextUnchanged(); if (!refreshedFlags) { - const error = new Error( + failConfirmation( "Opt-in changed remotely, but the updated flag state could not be confirmed", ); - this.logger.error("set opt-in confirmation failed", error); - throw error; } - const refreshedOptIn = refreshedFlags[flagKey]?.optIn; + const refreshedOptIn = this.flagsClient.getFetchedFlags()[flagKey]?.optIn; const scopedOptIn = scope === "user" ? refreshedOptIn?.userOptedIn @@ -1325,11 +1352,9 @@ export class ReflagClient { ? scopedOptIn === true : scopedOptIn !== true; if (!isConfirmed) { - const error = new Error( + failConfirmation( `Opt-in changed remotely, but the updated ${scope} membership was not reflected in the SDK`, ); - this.logger.error("set opt-in confirmation failed", error); - throw error; } return res; @@ -1425,6 +1450,15 @@ export class ReflagClient { }); } + private finishContextUpdate() { + if (!this.contextUpdateLoading) return; + + this.contextUpdateLoading = false; + if (this.state === "initializing") { + this.setState("initialized"); + } + } + private setState(state: State) { this.state = state; this.hooks.trigger("stateUpdated", state); diff --git a/packages/browser-sdk/src/flag/flags.ts b/packages/browser-sdk/src/flag/flags.ts index aa2c0812c..707c0723b 100644 --- a/packages/browser-sdk/src/flag/flags.ts +++ b/packages/browser-sdk/src/flag/flags.ts @@ -370,6 +370,9 @@ export class FlagsClient { } setContextWithoutFetch(context: ReflagContext) { + if (!deepEqual(this.context, context)) { + this.contextFetchVersion += 1; + } this.context = context; } @@ -616,12 +619,22 @@ export class FlagsClient { } this.refreshEvents.push(now); + const requestContextVersion = this.contextFetchVersion; const result = await this.fetchFlags(waitForVersion); - if (result) { - this.setFetchedFlags(result.flags, true, result.flagStateVersion); - return result.flags; + if (!result || requestContextVersion !== this.contextFetchVersion) { + return; } - return; + + if ( + result.flagStateVersion !== undefined && + this.fetchedFlagStateVersion !== undefined && + result.flagStateVersion < this.fetchedFlagStateVersion + ) { + return { ...this.fetchedFlags }; + } + + this.setFetchedFlags(result.flags, true, result.flagStateVersion); + return { ...this.fetchedFlags }; } private async setOverridesCache(overrides: FlagOverrides) { diff --git a/packages/browser-sdk/test/client.test.ts b/packages/browser-sdk/test/client.test.ts index 25c8099d4..6aca8392f 100644 --- a/packages/browser-sdk/test/client.test.ts +++ b/packages/browser-sdk/test/client.test.ts @@ -4,9 +4,39 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ReflagClient } from "../src/client"; import { FlagsClient } from "../src/flag/flags"; import { HttpClient } from "../src/httpClient"; +import { deferred } from "./deferred"; import { flagsResult } from "./mocks/handlers"; import { server } from "./mocks/server"; +function optInFlags(userOptedIn: boolean, targetingVersion = 1) { + return { + optInFlag: { + key: "optInFlag", + isEnabled: userOptedIn, + targetingVersion, + optInEnabled: true, + optIn: { + userOptedIn, + companyOptedIn: false, + isOptedIn: userOptedIn, + name: "Opt-in flag", + description: null, + }, + }, + }; +} + +function optInEvaluationResponse( + flagStateVersion: number, + userOptedIn: boolean, +) { + return HttpResponse.json({ + success: true, + flagStateVersion, + features: optInFlags(userOptedIn, flagStateVersion), + }); +} + describe("ReflagClient", () => { let client: ReflagClient; const httpClientPost = vi.spyOn(HttpClient.prototype as any, "post"); @@ -470,6 +500,112 @@ describe("ReflagClient", () => { }); }); + it("keeps the newest state when concurrent opt-in refreshes resolve out of order", async () => { + const olderRefresh = deferred(); + const newerRefresh = deferred(); + + server.use( + http.post( + "https://front.reflag.com/flags/opt-in", + async ({ request }) => { + const body = (await request.json()) as { optedIn: boolean }; + return HttpResponse.json({ + success: true, + flagStateVersion: body.optedIn ? 7 : 8, + }); + }, + ), + http.get( + "https://front.reflag.com/features/evaluated", + ({ request }) => { + const version = new URL(request.url).searchParams.get( + "waitForVersion", + ); + return version === "7" + ? olderRefresh.promise + : newerRefresh.promise; + }, + ), + ); + + client = new ReflagClient({ + publishableKey: "test-key-concurrent-opt-in", + user: { id: "user1" }, + bootstrappedFlags: optInFlags(false), + }); + await client.initialize(); + + const optInPromise = client.setOptIn("optInFlag", { optedIn: true }); + const optInExpectation = expect(optInPromise).rejects.toThrow( + "the updated user membership was not reflected in the SDK", + ); + const cancelPromise = client.setOptIn("optInFlag", { optedIn: false }); + await vi.waitFor(() => expect(httpClientGet).toHaveBeenCalledTimes(2)); + + newerRefresh.resolve(optInEvaluationResponse(8, false)); + await cancelPromise; + olderRefresh.resolve(optInEvaluationResponse(7, true)); + await optInExpectation; + + expect(client.getOptInFlags()[0]).toMatchObject({ + isEnabled: false, + userOptedIn: false, + isOptedIn: false, + }); + expect(client["flagsClient"].getFlagStateVersion()).toBe(8); + }); + + it("does not apply opt-in flags fetched for a previous context", async () => { + const previousContextRefresh = deferred(); + const currentContextRefresh = deferred(); + + server.use( + http.post("https://front.reflag.com/flags/opt-in", () => + HttpResponse.json({ success: true, flagStateVersion: 7 }), + ), + http.get( + "https://front.reflag.com/features/evaluated", + ({ request }) => { + const userId = new URL(request.url).searchParams.get( + "context.user.id", + ); + return userId === "user1" + ? previousContextRefresh.promise + : currentContextRefresh.promise; + }, + ), + ); + + client = new ReflagClient({ + publishableKey: "test-key-opt-in-context-change", + user: { id: "user1" }, + enableTracking: false, + bootstrappedFlags: optInFlags(false), + }); + await client.initialize(); + + const optInPromise = client.setOptIn("optInFlag", { optedIn: true }); + const optInExpectation = expect(optInPromise).rejects.toThrow( + "user context changed before the updated state could be confirmed", + ); + await vi.waitFor(() => expect(httpClientGet).toHaveBeenCalledTimes(1)); + + const contextUpdate = client.setContext({ user: { id: "user2" } }); + await vi.waitFor(() => expect(httpClientGet).toHaveBeenCalledTimes(2)); + currentContextRefresh.resolve(optInEvaluationResponse(8, false)); + await contextUpdate; + previousContextRefresh.resolve(optInEvaluationResponse(7, true)); + await optInExpectation; + + expect(client.getContext().user?.id).toBe("user2"); + expect(client.getOptInFlags()[0]).toMatchObject({ + isEnabled: false, + userOptedIn: false, + isOptedIn: false, + }); + expect(client["flagsClient"].getFlagStateVersion()).toBe(8); + }); + it("rejects when the refreshed SDK state does not confirm the membership change", async () => { server.use( http.post("https://front.reflag.com/flags/opt-in", () => @@ -761,6 +897,35 @@ describe("ReflagClient", () => { expect(client.getState()).toBe("initialized"); }); + + it("finishes loading when bootstrapped state supersedes a context fetch", async () => { + await client.initialize(); + + const contextFetch = deferred(); + vi.spyOn(client["flagsClient"], "setContext").mockReturnValue( + contextFetch.promise, + ); + + const contextUpdate = client.setContext({ + user: { id: "user2" }, + company: { id: "company2" }, + }); + expect(client.getState()).toBe("initializing"); + + client.applyBootstrappedState({ + context: { + user: { id: "user3" }, + company: { id: "company3" }, + }, + flags: {}, + flagStateVersion: 3, + }); + expect(client.getState()).toBe("initialized"); + + contextFetch.resolve(false); + await contextUpdate; + expect(client.getState()).toBe("initialized"); + }); }); describe("setContext warnings", () => { diff --git a/packages/browser-sdk/test/deferred.ts b/packages/browser-sdk/test/deferred.ts new file mode 100644 index 000000000..399f82289 --- /dev/null +++ b/packages/browser-sdk/test/deferred.ts @@ -0,0 +1,8 @@ +// TODO: Use Promise.withResolvers() when the configured TypeScript lib and supported runtimes include it. +export function deferred() { + let resolve!: (value: T) => void; + const promise = new Promise((promiseResolve) => { + resolve = promiseResolve; + }); + return { promise, resolve }; +} diff --git a/packages/browser-sdk/test/flags.test.ts b/packages/browser-sdk/test/flags.test.ts index 822e15177..522e464a4 100644 --- a/packages/browser-sdk/test/flags.test.ts +++ b/packages/browser-sdk/test/flags.test.ts @@ -9,6 +9,7 @@ import { validateFlagsResponse, } from "../src/flag/flags"; import { HttpClient } from "../src/httpClient"; +import { deferred } from "./deferred"; import { newCache, TEST_STALE_MS } from "./flagCache.test"; import { flagResponse, flagsResult } from "./mocks/handlers"; import { testLogger } from "./testLogger"; @@ -22,6 +23,29 @@ afterAll(() => { vi.useRealTimers(); }); +function evaluatedFlagsResponse( + flagStateVersion: number, + flagAEnabled: boolean, +) { + return new Response( + JSON.stringify({ + ...flagResponse, + flagStateVersion, + features: { + ...flagResponse.features, + flagA: { + ...flagResponse.features.flagA, + isEnabled: flagAEnabled, + }, + }, + }), + { + status: 200, + headers: { "Content-Type": "application/json" }, + }, + ); +} + function flagsClientFactory() { const { cache } = newCache(); const httpClient = new HttpClient("pk", { @@ -156,8 +180,9 @@ describe("FlagsClient", () => { json: () => Promise.resolve({ ...flagResponse, flagStateVersion: 22 }), } as Response); - await flagsClient.refreshFlags(22); + const refreshedFlags = await flagsClient.refreshFlags(22); + expect(refreshedFlags).not.toBe(flagsClient.getFetchedFlags()); expect(httpClient.get).toHaveBeenCalledTimes(1); const { params, path } = vi.mocked(httpClient.get).mock.calls[0][0]; const paramsObj = Object.fromEntries(new URLSearchParams(params)); @@ -229,6 +254,57 @@ describe("FlagsClient", () => { ); }); + test("does not let an older concurrent refresh replace newer flags", async () => { + const { newFlagsClient, httpClient } = flagsClientFactory(); + const flagsClient = newFlagsClient(); + await flagsClient.initialize(); + + const olderResponse = deferred(); + const newerResponse = deferred(); + vi.mocked(httpClient.get) + .mockReset() + .mockReturnValueOnce(olderResponse.promise) + .mockReturnValueOnce(newerResponse.promise); + + const olderRefresh = flagsClient.refreshFlags(6); + const newerRefresh = flagsClient.refreshFlags(7); + + newerResponse.resolve(evaluatedFlagsResponse(7, false)); + await newerRefresh; + olderResponse.resolve(evaluatedFlagsResponse(6, true)); + + await expect(olderRefresh).resolves.toEqual(flagsClient.getFetchedFlags()); + expect(flagsClient.getFlagStateVersion()).toBe(7); + expect(flagsClient.getFlags().flagA.isEnabled).toBe(false); + }); + + test("does not apply a refresh started for a previous context", async () => { + const { newFlagsClient, httpClient } = flagsClientFactory(); + const flagsClient = newFlagsClient(); + await flagsClient.initialize(); + + const previousContextResponse = deferred(); + vi.mocked(httpClient.get) + .mockReset() + .mockReturnValue(previousContextResponse.promise); + + const previousContextRefresh = flagsClient.refreshFlags(6); + flagsClient.setContextWithoutFetch({ user: { id: "789" } }); + flagsClient.setFetchedFlags( + { + ...flagsResult, + flagA: { ...flagsResult.flagA, isEnabled: false }, + }, + true, + 7, + ); + previousContextResponse.resolve(evaluatedFlagsResponse(6, true)); + + await expect(previousContextRefresh).resolves.toBeUndefined(); + expect(flagsClient.getFlagStateVersion()).toBe(7); + expect(flagsClient.getFlags().flagA.isEnabled).toBe(false); + }); + test("warns about missing context fields", async () => { const { newFlagsClient } = flagsClientFactory(); const flagsClient = newFlagsClient(); From 9bc0e7a87608f39da3f021b11d6131991c95a491 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Thu, 30 Jul 2026 13:49:30 +0200 Subject: [PATCH 3/6] Increase flag refresh rate limit --- packages/browser-sdk/src/flag/flags.ts | 2 +- packages/browser-sdk/test/flags.test.ts | 33 +++++++++++++++++++++++++ 2 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/browser-sdk/src/flag/flags.ts b/packages/browser-sdk/src/flag/flags.ts index 707c0723b..8253ea522 100644 --- a/packages/browser-sdk/src/flag/flags.ts +++ b/packages/browser-sdk/src/flag/flags.ts @@ -248,7 +248,7 @@ export interface CheckEvent { } const storageOverridesKey = `__reflag_overrides`; -const REFRESH_LIMIT_COUNT = 10; +const REFRESH_LIMIT_COUNT = 20; const REFRESH_LIMIT_WINDOW_MS = 60 * 1000; export type FlagOverrides = Record; diff --git a/packages/browser-sdk/test/flags.test.ts b/packages/browser-sdk/test/flags.test.ts index 522e464a4..5f8ee4c0b 100644 --- a/packages/browser-sdk/test/flags.test.ts +++ b/packages/browser-sdk/test/flags.test.ts @@ -305,6 +305,39 @@ describe("FlagsClient", () => { expect(flagsClient.getFlags().flagA.isEnabled).toBe(false); }); + test("rate limits refreshes after 20 requests in the refresh window", async () => { + const { newFlagsClient, httpClient } = flagsClientFactory(); + const flagsClient = newFlagsClient(); + await flagsClient.initialize(); + + vi.mocked(httpClient.get).mockClear(); + vi.mocked(testLogger.warn).mockClear(); + vi.mocked(httpClient.get).mockImplementation(() => + Promise.resolve(evaluatedFlagsResponse(1, true)), + ); + + await Promise.all( + Array.from({ length: 20 }, () => flagsClient.refreshFlags()), + ); + expect(httpClient.get).toHaveBeenCalledTimes(20); + + await expect(flagsClient.refreshFlags(22)).resolves.toBeUndefined(); + expect(httpClient.get).toHaveBeenCalledTimes(20); + expect(testLogger.warn).toHaveBeenCalledWith( + "[Flags] refresh rate limit exceeded", + ); + + vi.mocked(httpClient.get).mockImplementation(() => + Promise.resolve(evaluatedFlagsResponse(22, false)), + ); + await vi.advanceTimersByTimeAsync(60 * 1000); + const refreshedFlags = await flagsClient.refreshFlags(22); + expect(refreshedFlags).toEqual(flagsClient.getFetchedFlags()); + expect(httpClient.get).toHaveBeenCalledTimes(21); + expect(flagsClient.getFlagStateVersion()).toBe(22); + expect(flagsClient.getFlags().flagA.isEnabled).toBe(false); + }); + test("warns about missing context fields", async () => { const { newFlagsClient } = flagsClientFactory(); const flagsClient = newFlagsClient(); From ca5e7bc2e897096897afe0971a535c78f808af34 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Mon, 3 Aug 2026 14:35:55 +0200 Subject: [PATCH 4/6] docs: clarify end-user opt-in behavior --- packages/browser-sdk/README.md | 8 +++++++- packages/react-sdk/README.md | 6 +++++- packages/vue-sdk/README.md | 6 +++++- 3 files changed, 17 insertions(+), 3 deletions(-) diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index 2111c59dc..7e0d0a815 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -223,7 +223,13 @@ await reflagClient.setOptIn("huddle", { }); ``` -`scope` defaults to `"user"`. User scope requires a current `user.id`; company scope requires a current `company.id`. Setting `optedIn` to `false` cancels only the selected scope's opt-in, so cancelling user opt-in does not cancel matching company opt-in, and vice versa. After a successful mutation response, `setOptIn()` waits for the returned flag-state version, applies the refreshed flags locally, and synchronously notifies `flagsUpdated` listeners before its promise resolves. It rejects if the updated scoped membership cannot be confirmed in the SDK; the remote mutation may already have succeeded, so retrying the idempotent setter is safe. The `description` comes from the dedicated SDK-facing opt-in description configured in Reflag. +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. + +The `description` comes from the dedicated SDK-facing opt-in description configured in Reflag. ## Remote config diff --git a/packages/react-sdk/README.md b/packages/react-sdk/README.md index b049e3118..5e2c81380 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -676,7 +676,11 @@ function OptInList() { } ``` -`scope` defaults to `"user"`. User scope requires a current `user.id`; company scope requires a current `company.id`. Setting `optedIn` to `false` cancels only the selected scope's opt-in. For successful mutation responses, the returned promise resolves only after the Browser SDK has applied and confirmed the refreshed membership state and notified `useOptInFlags()`. React commits the resulting render using its normal scheduling. +By default, `useSetOptIn()` changes the opt-in for the current user, so the current context must include a `user.id`. To manage the current company's opt-in instead, pass `scope: "company"`; the context must then include a `company.id`. + +User and company opt-ins are managed independently. Setting `optedIn` to `false` removes the opt-in only for the selected scope. For example, cancelling a user's opt-in does not change the company's opt-in for the same flag. + +`setOptIn` returns a promise so you can wait for the new membership state to be synchronized. It resolves after the latest flag state has been applied, the requested membership change has been confirmed, and components using `useOptInFlags()` have been notified. React schedules the resulting render normally, so it may not yet be committed when the promise resolves. ### `useTrack()` diff --git a/packages/vue-sdk/README.md b/packages/vue-sdk/README.md index c000d1f41..8b3b38a4b 100644 --- a/packages/vue-sdk/README.md +++ b/packages/vue-sdk/README.md @@ -414,7 +414,11 @@ const setOptIn = useSetOptIn(); ``` -`scope` defaults to `"user"`. User scope requires a current `user.id`; company scope requires a current `company.id`. Setting `optedIn` to `false` cancels only the selected scope's opt-in. For successful mutation responses, the returned promise resolves only after the Browser SDK has applied and confirmed the refreshed membership state and notified `useOptInFlags()`. Vue commits the resulting render using its normal scheduling. +By default, `useSetOptIn()` changes the opt-in for the current user, so the current context must include a `user.id`. To manage the current company's opt-in instead, pass `scope: "company"`; the context must then include a `company.id`. + +User and company opt-ins are managed independently. Setting `optedIn` to `false` removes the opt-in only for the selected scope. For example, cancelling a user's opt-in does not change the company's opt-in for the same flag. + +`setOptIn` returns a promise so you can wait for the new membership state to be synchronized. It resolves after the latest flag state has been applied, the requested membership change has been confirmed, and components using `useOptInFlags()` have been notified. Vue schedules the resulting render normally, so it may not yet be committed when the promise resolves. ### `useTrack()` From c8d66b3471bc5ad507e9384a2b7b0dac3befd39c Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Mon, 3 Aug 2026 20:07:01 +0200 Subject: [PATCH 5/6] fix: harden opt-in flag refresh handling --- packages/browser-sdk/README.md | 10 --- packages/browser-sdk/src/flag/flagCache.ts | 1 + packages/browser-sdk/src/flag/flags.ts | 64 ++++++++++++++----- packages/browser-sdk/test/flagCache.test.ts | 12 +++- packages/browser-sdk/test/flags.test.ts | 69 +++++++++++++++++++++ 5 files changed, 131 insertions(+), 25 deletions(-) diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index 7e0d0a815..3253373a7 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -361,16 +361,6 @@ This eliminates loading states and improves performance by avoiding the initial Attributes given for the user/company/other context in the ReflagClient constructor can be updated for use in flag targeting evaluation with the `updateUser()`, `updateCompany()` and `updateOtherContext()` methods. They return a promise which resolves once the flags have been re-evaluated follow the update of the attributes. -The following shows how to let users self-opt-in for a new flag. The flag must have the rule `voiceHuddleOptIn IS true` set in the Reflag UI. - -```ts -// toggle opt-in for the voiceHuddle flag: -const { isEnabled } = reflagClient.getFlag("voiceHuddle"); -// this toggles the flag on/off. The promise returns once flag targeting has been -// re-evaluated. -await reflagClient.updateUser({ voiceHuddleOptIn: (!isEnabled).toString() }); -``` - > [!NOTE] > `user`/`company` attributes are also stored remotely on the Reflag servers and will automatically be used to evaluate flag targeting if the page is refreshed. ### setContext() diff --git a/packages/browser-sdk/src/flag/flagCache.ts b/packages/browser-sdk/src/flag/flagCache.ts index 6b6fff54c..ddad0fd7a 100644 --- a/packages/browser-sdk/src/flag/flagCache.ts +++ b/packages/browser-sdk/src/flag/flagCache.ts @@ -44,6 +44,7 @@ export function parseAPIFlagsResponse(flagsInput: any): RawFlags | undefined { const flags: RawFlags = {}; for (const key in flagsInput) { const flag = flagsInput[key]; + if (!isObject(flag)) return; const optIn = parseOptIn(flag.optIn); diff --git a/packages/browser-sdk/src/flag/flags.ts b/packages/browser-sdk/src/flag/flags.ts index 8253ea522..cf3dd6a18 100644 --- a/packages/browser-sdk/src/flag/flags.ts +++ b/packages/browser-sdk/src/flag/flags.ts @@ -281,6 +281,7 @@ export class FlagsClient { private cache: FlagCache; private fetchedFlags: RawFlags = {}; private fetchedFlagStateVersion: number | undefined; + private fetchedFlagsContextVersion = 0; private flagOverrides: FlagOverrides = {}; private flags: RawFlags = {}; private fallbackFlags: FallbackFlags = {}; @@ -347,7 +348,12 @@ export class FlagsClient { } if (!this.bootstrapped) { - this.applyFetchedFlagsResult(await this.maybeFetchFlags()); + const requestContextVersion = this.contextFetchVersion; + this.applyFetchedFlagsResult( + await this.maybeFetchFlags(requestContextVersion), + true, + requestContextVersion, + ); } // Apply overrides and trigger update if flags have changed @@ -384,31 +390,67 @@ export class FlagsClient { // Create a new fetched flags object making sure to clone the flags this.fetchedFlags = { ...fetchedFlags }; this.fetchedFlagStateVersion = flagStateVersion; + this.fetchedFlagsContextVersion = this.contextFetchVersion; this.warnMissingFlagContextFields(fetchedFlags); this.updateFlags(triggerEvent); } + private shouldApplyFetchedFlagsResult( + flagStateVersion: number | undefined, + requestContextVersion: number, + ) { + if (requestContextVersion !== this.contextFetchVersion) { + return false; + } + + // A result for the current context must replace flags that still belong to + // the previous context, even when the result is unversioned. + if (this.fetchedFlagsContextVersion !== requestContextVersion) { + return true; + } + + if (flagStateVersion === undefined) { + return this.fetchedFlagStateVersion === undefined; + } + + return ( + this.fetchedFlagStateVersion === undefined || + flagStateVersion >= this.fetchedFlagStateVersion + ); + } + private applyFetchedFlagsResult( result: FetchedFlagsResult | undefined, triggerEvent = true, + requestContextVersion = this.contextFetchVersion, ) { + if ( + !this.shouldApplyFetchedFlagsResult( + result?.flagStateVersion, + requestContextVersion, + ) + ) { + return false; + } + this.setFetchedFlags( result?.flags ?? {}, triggerEvent, result?.flagStateVersion, ); + return true; } async setContext(context: ReflagContext) { this.context = context; const requestVersion = ++this.contextFetchVersion; - const fetchedFlags = await this.maybeFetchFlags(); + const fetchedFlags = await this.maybeFetchFlags(requestVersion); if (requestVersion !== this.contextFetchVersion) { return false; } - this.applyFetchedFlagsResult(fetchedFlags); + this.applyFetchedFlagsResult(fetchedFlags, true, requestVersion); return true; } @@ -625,15 +667,7 @@ export class FlagsClient { return; } - if ( - result.flagStateVersion !== undefined && - this.fetchedFlagStateVersion !== undefined && - result.flagStateVersion < this.fetchedFlagStateVersion - ) { - return { ...this.fetchedFlags }; - } - - this.setFetchedFlags(result.flags, true, result.flagStateVersion); + this.applyFetchedFlagsResult(result, true, requestContextVersion); return { ...this.fetchedFlags }; } @@ -663,7 +697,9 @@ export class FlagsClient { } } - private async maybeFetchFlags(): Promise { + private async maybeFetchFlags( + requestContextVersion = this.contextFetchVersion, + ): Promise { if (this.config.offline) { return; } @@ -685,7 +721,7 @@ export class FlagsClient { flags: result.flags, flagStateVersion: result.flagStateVersion, }); - this.setFetchedFlags(result.flags, true, result.flagStateVersion); + this.applyFetchedFlagsResult(result, true, requestContextVersion); }) .catch(() => { // we don't care about the result, we just want to re-fetch diff --git a/packages/browser-sdk/test/flagCache.test.ts b/packages/browser-sdk/test/flagCache.test.ts index 3816e14d4..70c795b65 100644 --- a/packages/browser-sdk/test/flagCache.test.ts +++ b/packages/browser-sdk/test/flagCache.test.ts @@ -8,7 +8,11 @@ import { vitest, } from "vitest"; -import { CacheResult, FlagCache } from "../src/flag/flagCache"; +import { + CacheResult, + FlagCache, + parseAPIFlagsResponse, +} from "../src/flag/flagCache"; beforeEach(() => { vi.useFakeTimers(); @@ -42,6 +46,12 @@ export function newCache(): { }; } +describe("parseAPIFlagsResponse", () => { + test("rejects malformed flag entries without throwing", () => { + expect(parseAPIFlagsResponse({ flagA: null })).toBeUndefined(); + }); +}); + describe("cache", () => { const flags = { flagA: { isEnabled: true, key: "flagA", targetingVersion: 1 }, diff --git a/packages/browser-sdk/test/flags.test.ts b/packages/browser-sdk/test/flags.test.ts index 5f8ee4c0b..3dde13932 100644 --- a/packages/browser-sdk/test/flags.test.ts +++ b/packages/browser-sdk/test/flags.test.ts @@ -278,6 +278,33 @@ describe("FlagsClient", () => { expect(flagsClient.getFlags().flagA.isEnabled).toBe(false); }); + test("does not let an older context fetch replace a newer refresh", async () => { + const { newFlagsClient, httpClient } = flagsClientFactory(); + const flagsClient = newFlagsClient(); + await flagsClient.initialize(); + + const contextResponse = deferred(); + const refreshResponse = deferred(); + vi.mocked(httpClient.get) + .mockReset() + .mockReturnValueOnce(contextResponse.promise) + .mockReturnValueOnce(refreshResponse.promise); + + const contextUpdate = flagsClient.setContext({ user: { id: "789" } }); + await vi.waitFor(() => expect(httpClient.get).toHaveBeenCalledTimes(1)); + + const refresh = flagsClient.refreshFlags(7); + await vi.waitFor(() => expect(httpClient.get).toHaveBeenCalledTimes(2)); + + refreshResponse.resolve(evaluatedFlagsResponse(7, false)); + await refresh; + contextResponse.resolve(evaluatedFlagsResponse(6, true)); + + await expect(contextUpdate).resolves.toBe(true); + expect(flagsClient.getFlagStateVersion()).toBe(7); + expect(flagsClient.getFlags().flagA.isEnabled).toBe(false); + }); + test("does not apply a refresh started for a previous context", async () => { const { newFlagsClient, httpClient } = flagsClientFactory(); const flagsClient = newFlagsClient(); @@ -680,6 +707,48 @@ describe("FlagsClient", () => { ); }); + test("does not apply stale background revalidation for a previous context", async () => { + const { cache, newFlagsClient, httpClient } = flagsClientFactory(); + vi.mocked(httpClient.get).mockResolvedValue( + evaluatedFlagsResponse(6, true), + ); + + const firstClient = newFlagsClient(); + await firstClient.initialize(); + vi.advanceTimersByTime(TEST_STALE_MS + 1); + + const backgroundResponse = deferred(); + vi.mocked(httpClient.get) + .mockReset() + .mockReturnValue(backgroundResponse.promise); + + const flagsClient = newFlagsClient(undefined, { + staleWhileRevalidate: true, + }); + await flagsClient.initialize(); + + flagsClient.setContextWithoutFetch({ user: { id: "789" } }); + flagsClient.setFetchedFlags( + { + ...flagsResult, + flagA: { ...flagsResult.flagA, isEnabled: false }, + }, + true, + 7, + ); + + const cacheSet = vi.spyOn(cache, "set"); + const setFetchedFlags = vi.spyOn(flagsClient, "setFetchedFlags"); + backgroundResponse.resolve(evaluatedFlagsResponse(6, true)); + + await vi.waitFor(() => expect(cacheSet).toHaveBeenCalledTimes(1)); + await vi.advanceTimersByTimeAsync(0); + + expect(setFetchedFlags).not.toHaveBeenCalled(); + expect(flagsClient.getFlagStateVersion()).toBe(7); + expect(flagsClient.getFlags().flagA.isEnabled).toBe(false); + }); + test("expires cache eventually", async () => { // change the response so we can validate that we'll serve the stale cache const { newFlagsClient, httpClient } = flagsClientFactory(); From 0518142cd7505fbf37b277b7eeb17927159b7119 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Tue, 4 Aug 2026 10:46:28 +0200 Subject: [PATCH 6/6] fix: refresh bootstrapped opt-in metadata on demand --- .changeset/end-user-opt-in-sdk.md | 2 +- packages/browser-sdk/README.md | 10 ++- packages/browser-sdk/src/client.ts | 27 +++++++- packages/browser-sdk/src/flag/flags.ts | 27 ++++++++ packages/browser-sdk/test/client.test.ts | 86 +++++++++++++++++++++++- packages/react-sdk/README.md | 18 +++-- packages/react-sdk/src/index.tsx | 3 +- packages/react-sdk/test/usage.test.tsx | 63 +++++++++++++++++ packages/vue-sdk/README.md | 8 ++- packages/vue-sdk/src/types.ts | 3 +- 10 files changed, 229 insertions(+), 18 deletions(-) diff --git a/.changeset/end-user-opt-in-sdk.md b/.changeset/end-user-opt-in-sdk.md index b37cfc86b..bb893fcb1 100644 --- a/.changeset/end-user-opt-in-sdk.md +++ b/.changeset/end-user-opt-in-sdk.md @@ -4,4 +4,4 @@ "@reflag/vue-sdk": minor --- -Add end-user opt-in helpers for listing opt-in-enabled flags and setting whether the current user or company has opted into a flag. +Add end-user opt-in helpers for listing opt-in-enabled flags and setting whether the current user or company has opted into a flag. Bootstrapped clients refresh missing browser opt-in metadata on demand when opt-in flags are requested. diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index 3253373a7..8e62b423b 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -231,6 +231,8 @@ User and company opt-ins are managed independently. Setting `optedIn` to `false` The `description` comes from the dedicated SDK-facing opt-in description configured in Reflag. +When the client was bootstrapped without browser opt-in metadata, the first `getOptInFlags()` call starts one evaluated-flags refresh. The call returns the currently available list synchronously, and `flagsUpdated` is emitted when the refreshed list is available. + ## Remote config Remote config is a dynamic and flexible approach to configuring flag behavior outside of your app – without needing to re-deploy it. @@ -260,7 +262,7 @@ generate a `check` event, contrary to the `config` property on the object return ## Server-side rendering and bootstrapping -For server-side rendered applications, you can eliminate the initial network request by bootstrapping the client with pre-fetched flag data. +For server-side rendered applications, you can render immediately with pre-fetched flag data by bootstrapping the client. ### Init options bootstrapped @@ -316,7 +318,7 @@ const reflagClient = new ReflagClient({ bootstrappedState, // Contains context, flags, and optional flagStateVersion }); -await reflagClient.initialize(); // Initializes all but flags +await reflagClient.initialize(); const { isEnabled } = reflagClient.getFlag("huddle"); ``` @@ -328,6 +330,8 @@ 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`. +The bootstrap payload is available synchronously for the initial render. If the application requests opt-in flags and the bootstrap payload does not include browser opt-in metadata, the browser SDK performs one evaluated-flags refresh on demand. Bootstrapped applications that do not use opt-in data do not make this request. If the refresh fails, the bootstrapped flags remain in use. + If you previously used `bootstrappedFlags`, migrate like this: ```typescript @@ -352,7 +356,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. -This eliminates loading states and improves performance by avoiding the initial flags API call. +This eliminates loading states and removes the initial render's dependency on the flags API. ## Context management diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index b848d15af..4ab86974f 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -343,12 +343,14 @@ export type InitOptions = ReflagDeprecatedContext & { toolbar?: ToolbarOptions; /** - * Pre-fetched evaluated state to be used instead of fetching it from the server. + * Pre-fetched evaluated state used for the initial flag state. + * If opt-in flags are requested and browser opt-in metadata is missing, the client refreshes it on demand. */ bootstrappedState?: BootstrappedState; /** - * Pre-fetched flags to be used instead of fetching them from the server. + * Pre-fetched flags used for the initial flag state. + * If opt-in flags are requested and browser opt-in metadata is missing, the client refreshes them on demand. * @deprecated Use `bootstrappedState` instead. */ bootstrappedFlags?: RawFlags; @@ -484,6 +486,7 @@ function shouldShowToolbar(opts: InitOptions) { export class ReflagClient { private state: State = "idle"; private contextUpdateLoading = false; + private optInFlagsRequested = false; private readonly publishableKey: string; private context: ReflagContext; private config: Config; @@ -684,6 +687,9 @@ export class ReflagClient { } await this.flagsClient.initialize(); + if (this.optInFlagsRequested) { + void this.refreshOptInMetadataIfNeeded(); + } // Open SSE after the initial flag load. The pubsub server replays the // latest flag-update message, including `flagStateVersion`, so @@ -1000,11 +1006,15 @@ export class ReflagClient { this.flagsClient.setContextWithoutFetch(newContext); if (!shouldIgnoreIncomingFlags) { + this.flagsClient.resetOptInMetadataRefresh(); this.flagsClient.setFetchedFlags( bootstrappedState.flags, triggerEvent, incomingFlagStateVersion, ); + if (this.optInFlagsRequested) { + void this.refreshOptInMetadataIfNeeded(); + } } if (contextChanged) { @@ -1215,6 +1225,11 @@ export class ReflagClient { * Returns opt-in-enabled flags for the current context. */ getOptInFlags(): OptInFlag[] { + this.optInFlagsRequested = true; + if (this.state === "initialized") { + void this.refreshOptInMetadataIfNeeded(); + } + return Object.values(this.getFlags()).flatMap((flag) => { if (flag.optInEnabled !== true || !flag.optIn) return []; @@ -1450,6 +1465,14 @@ export class ReflagClient { }); } + private async refreshOptInMetadataIfNeeded() { + try { + await this.flagsClient.refreshOptInMetadataIfNeeded(); + } catch (error) { + this.logger.error("error refreshing opt-in flag metadata", error); + } + } + private finishContextUpdate() { if (!this.contextUpdateLoading) return; diff --git a/packages/browser-sdk/src/flag/flags.ts b/packages/browser-sdk/src/flag/flags.ts index cf3dd6a18..114a71874 100644 --- a/packages/browser-sdk/src/flag/flags.ts +++ b/packages/browser-sdk/src/flag/flags.ts @@ -286,6 +286,7 @@ export class FlagsClient { private flags: RawFlags = {}; private fallbackFlags: FallbackFlags = {}; private contextFetchVersion = 0; + private optInMetadataRefreshAttemptContextVersion: number | undefined; private storage: StorageAdapter; private refreshEvents: number[] = []; private enqueueBulkEvent?: (event: BulkEvent) => Promise; @@ -375,6 +376,32 @@ export class FlagsClient { return this.fetchedFlags; } + resetOptInMetadataRefresh() { + this.optInMetadataRefreshAttemptContextVersion = undefined; + } + + async refreshOptInMetadataIfNeeded() { + if (!this.bootstrapped) return; + + const fetchedFlags = Object.values(this.fetchedFlags); + const hasOptInMetadata = + fetchedFlags.length > 0 && + fetchedFlags.every( + (flag) => + flag.optInEnabled === false || + (flag.optInEnabled === true && flag.optIn !== undefined), + ); + if (hasOptInMetadata) return; + + const contextVersion = this.contextFetchVersion; + if (this.optInMetadataRefreshAttemptContextVersion === contextVersion) { + return; + } + + this.optInMetadataRefreshAttemptContextVersion = contextVersion; + return this.refreshFlags(this.fetchedFlagStateVersion); + } + setContextWithoutFetch(context: ReflagContext) { if (!deepEqual(this.context, context)) { this.contextFetchVersion += 1; diff --git a/packages/browser-sdk/test/client.test.ts b/packages/browser-sdk/test/client.test.ts index 6aca8392f..0713c9062 100644 --- a/packages/browser-sdk/test/client.test.ts +++ b/packages/browser-sdk/test/client.test.ts @@ -316,6 +316,89 @@ describe("ReflagClient", () => { ]); }); + it("refreshes missing opt-in metadata on demand after bootstrapping", async () => { + server.use( + http.get("https://front.reflag.com/features/evaluated", () => + optInEvaluationResponse(2, false), + ), + ); + + client = new ReflagClient({ + publishableKey: "test-key-bootstrap-opt-in-metadata", + user: { id: "user1" }, + enableTracking: false, + bootstrappedFlags: { + optInFlag: { + key: "optInFlag", + isEnabled: false, + targetingVersion: 1, + }, + }, + }); + + await client.initialize(); + expect(httpClientGet).not.toHaveBeenCalled(); + + expect(client.getOptInFlags()).toEqual([]); + await vi.waitFor(() => { + expect(client.getOptInFlags()).toEqual([ + { + key: "optInFlag", + name: "Opt-in flag", + description: null, + isEnabled: false, + userOptedIn: false, + companyOptedIn: false, + isOptedIn: false, + }, + ]); + }); + expect(httpClientGet).toHaveBeenCalledTimes(1); + }); + + it("waits for the bootstrapped flag state version when refreshing opt-in metadata", async () => { + let requestedWaitForVersion: string | null = null; + server.use( + http.get( + "https://front.reflag.com/features/evaluated", + ({ request }) => { + requestedWaitForVersion = new URL(request.url).searchParams.get( + "waitForVersion", + ); + + return optInEvaluationResponse( + requestedWaitForVersion === "2" ? 2 : 1, + false, + ); + }, + ), + ); + + client = new ReflagClient({ + publishableKey: "test-key-versioned-bootstrap-opt-in-metadata", + enableTracking: false, + bootstrappedState: { + context: { user: { id: "user1" } }, + flags: { + optInFlag: { + key: "optInFlag", + isEnabled: false, + targetingVersion: 1, + }, + }, + flagStateVersion: 2, + }, + }); + + await client.initialize(); + expect(client.getOptInFlags()).toEqual([]); + + await vi.waitFor(() => { + expect(client.getOptInFlags()).toHaveLength(1); + }); + expect(requestedWaitForVersion).toBe("2"); + }); + it("posts opt-in requests and refreshes flags at the returned state version", async () => { const flagsUpdated = vi.fn(); const requests: string[] = []; @@ -1065,8 +1148,9 @@ describe("ReflagClient", () => { // After initialize, flagsClient should be properly initialized expect(client["flagsClient"]["initialized"]).toBe(true); - // maybeFetchFlags should not be called since flagsClient is already bootstrapped + // No fetch is needed until opt-in data is requested. expect(maybeFetchFlags).not.toHaveBeenCalled(); + expect(httpClientGet).not.toHaveBeenCalled(); }); it("ignores same-context bootstrapped state with an older flagStateVersion", () => { diff --git a/packages/react-sdk/README.md b/packages/react-sdk/README.md index 5e2c81380..d98f9d77e 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -223,15 +223,15 @@ You can pass a simple boolean to force the toolbar to appear/disappear: ## Server-side rendering and bootstrapping -For server-side rendered applications, you can eliminate the initial network request by bootstrapping the client with pre-fetched flag data using the `ReflagBootstrappedProvider`. +For server-side rendered applications, you can render immediately with pre-fetched flag data using the `ReflagBootstrappedProvider`. -Bootstrapping is also the recommended setup if you want the most resilient feature flag architecture in React. Instead of depending on an initial client-side request to Reflag, the client can render immediately from flags provided by your server. +Bootstrapping is also the recommended setup if you want the most resilient feature flag architecture in React. The client renders immediately from flags provided by your server instead of making its initial render depend on a request to Reflag. -If you want "bullet proof feature flags" in a React application, use React bootstrapping together with `flagsFallbackProvider` in the Node SDK. The fallback provider helps your server start with the latest saved snapshot if it cannot reach Reflag during initialization, and bootstrapping lets the React client use those server-provided flags without needing its own live fetch on first render. +If you want "bullet proof feature flags" in a React application, use React bootstrapping together with `flagsFallbackProvider` in the Node SDK. The fallback provider helps your server start with the latest saved snapshot if it cannot reach Reflag during initialization, and bootstrapping gives the React client server-provided flags for its first render. ### Using `ReflagBootstrappedProvider` -The `` component is a specialized version of `ReflagProvider` designed for server-side rendering, preloaded flag scenarios, and high-reliability setups. Instead of fetching flags on initialization, it uses pre-fetched evaluated state, resulting in faster initial page loads, better SSR compatibility, and a more resilient startup path for React applications. +The `` component is a specialized version of `ReflagProvider` designed for server-side rendering, preloaded flag scenarios, and high-reliability setups. It uses pre-fetched evaluated state for the initial render, resulting in faster initial page loads, better SSR compatibility, and a more resilient startup path for React applications. ```tsx import { useState, useEffect } from "react"; @@ -368,7 +368,7 @@ function HuddleFeature() { } ``` -This approach eliminates loading states and improves performance by avoiding the initial flags API call. +This approach eliminates loading states and removes the initial render's dependency on the flags API. ### Next.js App Router example @@ -562,7 +562,7 @@ The `` initializes the Reflag SDK, fetches flags and starts list ## `` component -The `` is a specialized version of the `ReflagProvider` that uses pre-fetched flag data instead of making network requests during initialization. This is ideal for server-side rendering scenarios. +The `` is a specialized version of the `ReflagProvider` that uses pre-fetched flag data for the initial render. This is ideal for server-side rendering scenarios. The component accepts the following props: @@ -598,9 +598,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. > +> The bootstrap payload is used synchronously for SSR and the initial client render. If `useOptInFlags()` is used and the bootstrap payload does not include browser opt-in metadata, the browser SDK performs one evaluated-flags refresh on demand. Applications that do not use opt-in data do not make this request. If the refresh fails, the bootstrapped flags remain in use. +> > If you want live flag updates to continue working after bootstrapping, use a recent `@reflag/node-sdk` so `getFlagsForBootstrap()` includes `flagStateVersion`. > -> 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. +> 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. ## Hooks @@ -682,6 +684,8 @@ User and company opt-ins are managed independently. Setting `optedIn` to `false` `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. +When a bootstrapped payload does not contain browser opt-in metadata, `useOptInFlags()` automatically requests it once and updates after the evaluated flags arrive. + ### `useTrack()` `useTrack()` lets you send custom events to Reflag. Use this whenever a user _uses_ a feature. These events can be used to analyze feature usage in Reflag. diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index 2f0a6bec3..b7e55e8dd 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -411,7 +411,8 @@ export function ReflagProvider({ export type ReflagBootstrappedProps = ReflagPropsBase & ReflagInitOptionsBase & { /** - * Pre-fetched flags to be used instead of fetching them from the server. + * Pre-fetched flags used for the initial render. If opt-in flags are requested and + * browser opt-in metadata is missing, the browser client refreshes them on demand. */ flags: BootstrappedFlags; }; diff --git a/packages/react-sdk/test/usage.test.tsx b/packages/react-sdk/test/usage.test.tsx index 69403469b..3bea5e0d4 100644 --- a/packages/react-sdk/test/usage.test.tsx +++ b/packages/react-sdk/test/usage.test.tsx @@ -975,6 +975,69 @@ describe("opt-in hooks", () => { unmount(); }); + test("useOptInFlags refreshes missing metadata after bootstrap", async () => { + let evaluatedRequests = 0; + server.use( + http.get(/\/features\/evaluated$/, () => { + evaluatedRequests += 1; + return HttpResponse.json({ + success: true, + flagStateVersion: 2, + features: { + optInFlag: { + key: "optInFlag", + isEnabled: false, + targetingVersion: 1, + optInEnabled: true, + optIn: { + userOptedIn: false, + companyOptedIn: false, + isOptedIn: false, + name: "Opt-in flag", + description: "Try it early", + }, + }, + }, + }); + }), + ); + + const bootstrapFlags: BootstrappedFlags = { + context: { user, company, other }, + flagStateVersion: 1, + flags: { + optInFlag: { + key: "optInFlag", + isEnabled: false, + targetingVersion: 1, + }, + }, + }; + + const { result, unmount } = renderHook(() => useOptInFlags(), { + wrapper: ({ children }) => + getBootstrapProvider(bootstrapFlags, { children }), + }); + + await waitFor(() => expect(evaluatedRequests).toBe(1)); + await waitFor(() => { + expect(result.current).toEqual([ + { + key: "optInFlag", + name: "Opt-in flag", + description: "Try it early", + isEnabled: false, + userOptedIn: false, + companyOptedIn: false, + isOptedIn: false, + }, + ]); + }); + expect(evaluatedRequests).toBe(1); + + unmount(); + }); + test("useSetOptIn delegates to the browser client", async () => { const setOptIn = vi .spyOn(ReflagClient.prototype, "setOptIn") diff --git a/packages/vue-sdk/README.md b/packages/vue-sdk/README.md index 8b3b38a4b..6cc3c3807 100644 --- a/packages/vue-sdk/README.md +++ b/packages/vue-sdk/README.md @@ -212,7 +212,7 @@ If you want more control over loading screens, `useIsLoading()` returns a `Ref` component -The `` component is a specialized version of `ReflagProvider` designed for server-side rendering and preloaded flag scenarios. Instead of fetching flags on initialization, it uses pre-fetched flags, resulting in faster initial page loads and better SSR compatibility. +The `` component is a specialized version of `ReflagProvider` designed for server-side rendering and preloaded flag scenarios. It uses pre-fetched flags for the initial render, resulting in faster initial page loads and better SSR compatibility. ### Usage @@ -259,6 +259,8 @@ 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`. +The bootstrap payload is used synchronously for SSR and the initial client render. If `useOptInFlags()` is used and the bootstrap payload does not include browser opt-in metadata, the browser SDK performs one evaluated-flags refresh on demand. Applications that do not use opt-in data do not make this request. If the refresh fails, the bootstrapped flags remain in use. + Here's an example using the Node.js SDK: ```js @@ -291,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] -> 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. +> 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. ## `` component @@ -420,6 +422,8 @@ User and company opt-ins are managed independently. Setting `optedIn` to `false` `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. +When a bootstrapped payload does not contain browser opt-in metadata, `useOptInFlags()` automatically requests it once and updates after the evaluated flags arrive. + ### `useTrack()` `useTrack()` returns a function which lets you send custom events to Reflag. It takes a string argument with the event name and optionally an object with properties to attach the event. diff --git a/packages/vue-sdk/src/types.ts b/packages/vue-sdk/src/types.ts index 34dad2b4f..d9d5b57fd 100644 --- a/packages/vue-sdk/src/types.ts +++ b/packages/vue-sdk/src/types.ts @@ -153,7 +153,8 @@ export type ReflagProps = ReflagInitOptionsBase & export type ReflagBootstrappedProps = ReflagInitOptionsBase & ReflagBaseProps & { /** - * Pre-fetched flags to be used instead of fetching them from the server. + * Pre-fetched flags used for the initial render. If opt-in flags are requested and + * browser opt-in metadata is missing, the browser client refreshes them on demand. */ flags: BootstrappedFlags; };