diff --git a/.changeset/fuzzy-dodos-opt-in.md b/.changeset/fuzzy-dodos-opt-in.md new file mode 100644 index 000000000..51e4fdfb2 --- /dev/null +++ b/.changeset/fuzzy-dodos-opt-in.md @@ -0,0 +1,7 @@ +--- +"@reflag/browser-sdk": patch +"@reflag/react-sdk": patch +"@reflag/vue-sdk": patch +--- + +Fix React and Vue opt-in flag keys to respect generated flag types, and return reliable loading state from `useOptInFlags()` while bootstrapped clients fetch opt-in metadata. React's hook also supports Suspense. diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index 8e62b423b..bc7364a98 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -212,6 +212,7 @@ If a flag has end-user opt-in enabled in Reflag, you can list the opt-in options ```ts const optInFlags = reflagClient.getOptInFlags(); +const isLoadingOptInFlags = reflagClient.getIsLoadingOptInFlags(); // [{ key, name, description, isEnabled, userOptedIn, companyOptedIn, isOptedIn }] await reflagClient.setOptIn("huddle", { optedIn: true }); @@ -231,7 +232,9 @@ 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. +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. + +Listen for `optInFlagsLoadingUpdated` to update UI when this loading state changes. `flagsUpdated` is emitted when a successful refresh updates the list. ## Remote config @@ -330,7 +333,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`. -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 a bootstrapped application requests opt-in flags, the browser SDK performs one flags refresh. Applications that do not request opt-in data do not make this request. If you previously used `bootstrappedFlags`, migrate like this: @@ -506,10 +509,11 @@ reflagClient.track("huddle", { voiceHuddle: true }); ## Event listeners -Event listeners allow for capturing various events occurring in the `ReflagClient`. This is useful to build integrations with other system or for various debugging purposes. There are 5 kinds of events: +Event listeners allow for capturing various events occurring in the `ReflagClient`. This is useful to build integrations with other system or for various debugging purposes. The available events are: - `check`: Your code used `isEnabled` or `config` for a flag - `flagsUpdated`: Flags were updated. Either because they were loaded as part of initialization or because the user/company updated +- `optInFlagsLoadingUpdated`: The opt-in flag loading state changed - `user`: User information updated (similar to the `identify` call used in tracking terminology) - `company`: Company information updated (sometimes to the `group` call used in tracking terminology) - `track`: Track event occurred. diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 4ab86974f..66b942d65 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -344,13 +344,13 @@ export type InitOptions = ReflagDeprecatedContext & { /** * 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. + * The client fetches opt-in metadata on demand when opt-in flags are requested. */ bootstrappedState?: BootstrappedState; /** * 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. + * The client fetches opt-in metadata on demand when opt-in flags are requested. * @deprecated Use `bootstrappedState` instead. */ bootstrappedFlags?: RawFlags; @@ -486,7 +486,6 @@ 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; @@ -662,6 +661,9 @@ export class ReflagClient { this.flagsClient.onUpdated(() => { this.hooks.trigger("flagsUpdated", this.flagsClient.getFlags()); }); + this.flagsClient.onOptInFlagsLoadingUpdated((isLoading) => { + this.hooks.trigger("optInFlagsLoadingUpdated", isLoading); + }); } /** @@ -687,9 +689,6 @@ 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 @@ -1003,7 +1002,10 @@ export class ReflagClient { incomingFlagStateVersion < latestKnownFlagStateVersion); this.context = newContext; - this.flagsClient.setContextWithoutFetch(newContext); + this.flagsClient.setContextWithoutFetch( + newContext, + !shouldIgnoreIncomingFlags, + ); if (!shouldIgnoreIncomingFlags) { this.flagsClient.resetOptInMetadataRefresh(); @@ -1012,9 +1014,7 @@ export class ReflagClient { triggerEvent, incomingFlagStateVersion, ); - if (this.optInFlagsRequested) { - void this.refreshOptInMetadataIfNeeded(); - } + this.flagsClient.markBootstrappedStateApplied(); } if (contextChanged) { @@ -1225,10 +1225,7 @@ export class ReflagClient { * Returns opt-in-enabled flags for the current context. */ getOptInFlags(): OptInFlag[] { - this.optInFlagsRequested = true; - if (this.state === "initialized") { - void this.refreshOptInMetadataIfNeeded(); - } + this.flagsClient.requestOptInFlags(); return Object.values(this.getFlags()).flatMap((flag) => { if (flag.optInEnabled !== true || !flag.optIn) return []; @@ -1245,6 +1242,16 @@ export class ReflagClient { }); } + /** + * Returns whether opt-in flags are loading for the current context. + * + * Calling this method requests opt-in metadata if it is not already available. + */ + getIsLoadingOptInFlags(): boolean { + this.flagsClient.requestOptInFlags(); + return this.flagsClient.getIsLoadingOptInFlags(); + } + /** * Set whether the current user or company has opted into a flag. */ @@ -1465,14 +1472,6 @@ 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 114a71874..dacdb099c 100644 --- a/packages/browser-sdk/src/flag/flags.ts +++ b/packages/browser-sdk/src/flag/flags.ts @@ -274,6 +274,7 @@ type FlagsClientOptions = Partial & { export class FlagsClient { private initialized = false; private bootstrapped = false; + private initializationComplete = false; private rateLimiter: RateLimiter; private readonly logger: Logger; @@ -286,7 +287,17 @@ export class FlagsClient { private flags: RawFlags = {}; private fallbackFlags: FallbackFlags = {}; private contextFetchVersion = 0; + private optInFlagsRequested = false; + private optInFlagsLoading = false; + private optInFlagsLoadingGeneration = 0; private optInMetadataRefreshAttemptContextVersion: number | undefined; + private optInMetadataRefresh: + | { + contextVersion: number; + id: object; + promise: Promise; + } + | undefined; private storage: StorageAdapter; private refreshEvents: number[] = []; private enqueueBulkEvent?: (event: BulkEvent) => Promise; @@ -343,28 +354,43 @@ export class FlagsClient { } this.initialized = true; - const cachedOverrides = await this.getOverridesCache(); - if (Object.keys(cachedOverrides).length > 0) { - this.flagOverrides = { ...cachedOverrides, ...this.flagOverrides }; - } + let initializationSucceeded = false; + try { + const cachedOverrides = await this.getOverridesCache(); + if (Object.keys(cachedOverrides).length > 0) { + this.flagOverrides = { ...cachedOverrides, ...this.flagOverrides }; + } - if (!this.bootstrapped) { - const requestContextVersion = this.contextFetchVersion; - this.applyFetchedFlagsResult( - await this.maybeFetchFlags(requestContextVersion), - true, - requestContextVersion, - ); - } + if (!this.bootstrapped) { + const requestContextVersion = this.contextFetchVersion; + this.applyFetchedFlagsResult( + await this.maybeFetchFlags(requestContextVersion), + true, + requestContextVersion, + ); + } - // Apply overrides and trigger update if flags have changed - this.updateFlags(); + // Apply overrides and trigger update if flags have changed + this.updateFlags(); + initializationSucceeded = true; + } finally { + this.initializationComplete = true; + + if (this.optInFlagsRequested) { + if (initializationSucceeded && this.bootstrapped) { + void this.refreshOptInMetadataIfNeeded(); + } else { + this.finishOptInFlagsLoading(this.optInFlagsLoadingGeneration); + } + } + } } /** * Stop the client. */ public stop() { + this.supersedeOptInFlagsLoading(false); this.abortController.abort(); } @@ -376,35 +402,117 @@ export class FlagsClient { return this.fetchedFlags; } + requestOptInFlags() { + this.optInFlagsRequested = true; + + if ( + !this.initializationComplete || + this.fetchedFlagsContextVersion !== this.contextFetchVersion + ) { + if (!this.bootstrapped || !this.hasOptInMetadataForCurrentContext()) { + this.ensureOptInFlagsLoading(); + } + return; + } + + if (!this.bootstrapped) { + this.finishOptInFlagsLoading(this.optInFlagsLoadingGeneration); + return; + } + + void this.refreshOptInMetadataIfNeeded(); + } + + getIsLoadingOptInFlags() { + return this.optInFlagsLoading; + } + + onOptInFlagsLoadingUpdated(callback: (isLoading: boolean) => void) { + const listener = () => callback(this.optInFlagsLoading); + this.eventTarget.addEventListener("optInFlagsLoadingUpdated", listener, { + signal: this.abortController.signal, + }); + } + resetOptInMetadataRefresh() { this.optInMetadataRefreshAttemptContextVersion = undefined; } - async refreshOptInMetadataIfNeeded() { - if (!this.bootstrapped) return; + markBootstrappedStateApplied() { + this.bootstrapped = true; + if (!this.optInFlagsRequested) 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; + if (this.hasOptInMetadataForCurrentContext()) { + this.finishOptInFlagsLoading(this.optInFlagsLoadingGeneration); + return; + } + + this.ensureOptInFlagsLoading(); + if (this.initializationComplete) { + void this.refreshOptInMetadataIfNeeded(); + } + } + + async refreshOptInMetadataIfNeeded(): Promise { + if (!this.bootstrapped || !this.optInFlagsRequested) return; + + if ( + !this.initializationComplete || + this.fetchedFlagsContextVersion !== this.contextFetchVersion + ) { + this.ensureOptInFlagsLoading(); + return; + } + + if (this.hasOptInMetadataForCurrentContext()) { + this.finishOptInFlagsLoading(this.optInFlagsLoadingGeneration); + return; + } const contextVersion = this.contextFetchVersion; - if (this.optInMetadataRefreshAttemptContextVersion === contextVersion) { + const pendingRefresh = this.optInMetadataRefresh; + if (pendingRefresh?.contextVersion === contextVersion) { + return pendingRefresh.promise; + } + + if ( + this.config.offline || + this.optInMetadataRefreshAttemptContextVersion === contextVersion + ) { + this.finishOptInFlagsLoading(this.optInFlagsLoadingGeneration); return; } this.optInMetadataRefreshAttemptContextVersion = contextVersion; - return this.refreshFlags(this.fetchedFlagStateVersion); + this.ensureOptInFlagsLoading(); + const loadingGeneration = this.optInFlagsLoadingGeneration; + const id = {}; + const promise = (async () => { + try { + await this.refreshFlags(this.fetchedFlagStateVersion); + } catch (error) { + this.logger.error("error refreshing opt-in flag metadata", error); + } finally { + if (this.optInMetadataRefresh?.id === id) { + this.optInMetadataRefresh = undefined; + } + this.finishOptInFlagsLoading(loadingGeneration); + } + })(); + + this.optInMetadataRefresh = { contextVersion, id, promise }; + return promise; } - setContextWithoutFetch(context: ReflagContext) { - if (!deepEqual(this.context, context)) { + setContextWithoutFetch( + context: ReflagContext, + invalidatePendingFetches = false, + ) { + if (!deepEqual(this.context, context) || invalidatePendingFetches) { this.contextFetchVersion += 1; + if (this.optInFlagsRequested) { + this.startOptInFlagsLoading(); + } } this.context = context; } @@ -471,14 +579,25 @@ export class FlagsClient { async setContext(context: ReflagContext) { this.context = context; const requestVersion = ++this.contextFetchVersion; - const fetchedFlags = await this.maybeFetchFlags(requestVersion); + this.optInMetadataRefreshAttemptContextVersion = requestVersion; + const loadingGeneration = this.optInFlagsRequested + ? this.startOptInFlagsLoading() + : undefined; - if (requestVersion !== this.contextFetchVersion) { - return false; - } + try { + const fetchedFlags = await this.maybeFetchFlags(requestVersion); - this.applyFetchedFlagsResult(fetchedFlags, true, requestVersion); - return true; + if (requestVersion !== this.contextFetchVersion) { + return false; + } + + this.applyFetchedFlagsResult(fetchedFlags, true, requestVersion); + return true; + } finally { + if (loadingGeneration !== undefined) { + this.finishOptInFlagsLoading(loadingGeneration); + } + } } updateFlags(triggerEvent = true) { @@ -799,6 +918,52 @@ export class FlagsClient { }; } + private hasOptInMetadataForCurrentContext() { + if (this.fetchedFlagsContextVersion !== this.contextFetchVersion) { + return false; + } + + const fetchedFlags = Object.values(this.fetchedFlags); + return ( + fetchedFlags.length > 0 && + fetchedFlags.every( + (flag) => + flag.optInEnabled === false || + (flag.optInEnabled === true && flag.optIn !== undefined), + ) + ); + } + + private ensureOptInFlagsLoading() { + if (!this.optInFlagsLoading) { + this.startOptInFlagsLoading(); + } + return this.optInFlagsLoadingGeneration; + } + + private startOptInFlagsLoading() { + this.optInFlagsLoadingGeneration += 1; + this.setOptInFlagsLoading(true); + return this.optInFlagsLoadingGeneration; + } + + private finishOptInFlagsLoading(generation: number) { + if (generation !== this.optInFlagsLoadingGeneration) return; + this.setOptInFlagsLoading(false); + } + + private supersedeOptInFlagsLoading(isLoading: boolean) { + this.optInFlagsLoadingGeneration += 1; + this.setOptInFlagsLoading(isLoading); + } + + private setOptInFlagsLoading(isLoading: boolean) { + if (this.optInFlagsLoading === isLoading) return; + + this.optInFlagsLoading = isLoading; + this.eventTarget.dispatchEvent({ type: "optInFlagsLoadingUpdated" }); + } + private mergeFlags(fetchedFlags: RawFlags, overrides: FlagOverrides) { const mergedFlags: RawFlags = {}; // merge fetched flags with overrides into `this.flags` diff --git a/packages/browser-sdk/src/hooksManager.ts b/packages/browser-sdk/src/hooksManager.ts index 7692e6d25..c715f4b32 100644 --- a/packages/browser-sdk/src/hooksManager.ts +++ b/packages/browser-sdk/src/hooksManager.ts @@ -10,6 +10,7 @@ export interface HookArgs { stateUpdated: State; check: CheckEvent; flagsUpdated: RawFlags; + optInFlagsLoadingUpdated: boolean; /** * @deprecated Use `flagsUpdated` instead. @@ -36,6 +37,7 @@ export class HooksManager { stateUpdated: ((arg0: State) => void)[]; check: ((arg0: CheckEvent) => void)[]; flagsUpdated: ((arg0: RawFlags) => void)[]; + optInFlagsLoadingUpdated: ((arg0: boolean) => void)[]; user: ((arg0: UserContext) => void)[]; company: ((arg0: CompanyContext) => void)[]; track: ((arg0: TrackEvent) => void)[]; @@ -43,6 +45,7 @@ export class HooksManager { stateUpdated: [], check: [], flagsUpdated: [], + optInFlagsLoadingUpdated: [], user: [], company: [], track: [], @@ -75,6 +78,9 @@ export class HooksManager { event: THookType, arg: HookArgs[THookType], ): void { - this.hooks[this._adjustEvent(event)].forEach((hook) => hook(arg as any)); + const hooks = this.hooks[this._adjustEvent(event)] as Array< + (value: HookArgs[THookType]) => void + >; + hooks.forEach((hook) => hook(arg)); } } diff --git a/packages/browser-sdk/test/client.test.ts b/packages/browser-sdk/test/client.test.ts index 0713c9062..1c3a66e72 100644 --- a/packages/browser-sdk/test/client.test.ts +++ b/packages/browser-sdk/test/client.test.ts @@ -356,6 +356,230 @@ describe("ReflagClient", () => { expect(httpClientGet).toHaveBeenCalledTimes(1); }); + it("reports opt-in flags as loading during the initial flag fetch", async () => { + client = new ReflagClient({ + publishableKey: "test-key-initial-opt-in-loading", + user: { id: "user1" }, + enableTracking: false, + }); + + expect(client.getIsLoadingOptInFlags()).toBe(true); + await client.initialize(); + expect(client.getIsLoadingOptInFlags()).toBe(false); + }); + + it("reports complete bootstrapped opt-in metadata as ready immediately", async () => { + client = new ReflagClient({ + publishableKey: "test-key-complete-bootstrap-opt-in-metadata", + user: { id: "user1" }, + enableTracking: false, + bootstrappedFlags: optInFlags(false), + }); + + expect(client.getIsLoadingOptInFlags()).toBe(false); + await client.initialize(); + expect(client.getIsLoadingOptInFlags()).toBe(false); + expect(httpClientGet).not.toHaveBeenCalled(); + }); + + it("reports missing bootstrapped opt-in metadata as loading until refresh succeeds", async () => { + const response = deferred(); + server.use( + http.get( + "https://front.reflag.com/features/evaluated", + () => response.promise, + ), + ); + + client = new ReflagClient({ + publishableKey: "test-key-loading-bootstrap-opt-in-metadata", + user: { id: "user1" }, + enableTracking: false, + bootstrappedFlags: { + optInFlag: { + key: "optInFlag", + isEnabled: false, + targetingVersion: 1, + }, + }, + }); + const loadingUpdated = vi.fn(); + client.on("optInFlagsLoadingUpdated", loadingUpdated); + + await client.initialize(); + expect(client.getIsLoadingOptInFlags()).toBe(true); + await vi.waitFor(() => expect(httpClientGet).toHaveBeenCalledTimes(1)); + + response.resolve(optInEvaluationResponse(2, false)); + + await vi.waitFor(() => { + expect(client.getIsLoadingOptInFlags()).toBe(false); + }); + expect(client.getOptInFlags()).toHaveLength(1); + expect(loadingUpdated).toHaveBeenCalledWith(true); + expect(loadingUpdated).toHaveBeenLastCalledWith(false); + }); + + it("stops loading opt-in flags when the metadata refresh fails", async () => { + server.use( + http.get("https://front.reflag.com/features/evaluated", () => + HttpResponse.json({ success: false }, { status: 500 }), + ), + ); + + client = new ReflagClient({ + publishableKey: "test-key-failed-bootstrap-opt-in-metadata", + user: { id: "user1" }, + enableTracking: false, + bootstrappedFlags: { + optInFlag: { + key: "optInFlag", + isEnabled: false, + targetingVersion: 1, + }, + }, + }); + + await client.initialize(); + expect(client.getIsLoadingOptInFlags()).toBe(true); + + await vi.waitFor(() => { + expect(client.getIsLoadingOptInFlags()).toBe(false); + }); + expect(httpClientGet).toHaveBeenCalledTimes(1); + expect(client.getOptInFlags()).toEqual([]); + }); + + it("keeps opt-in loading tied to the newest context fetch", async () => { + const previousContextResponse = deferred(); + const currentContextResponse = deferred(); + const previousContextSettled = vi.fn(); + + server.use( + http.get( + "https://front.reflag.com/features/evaluated", + ({ request }) => { + const userId = new URL(request.url).searchParams.get( + "context.user.id", + ); + if (userId === "user1") { + return previousContextResponse.promise.then((response) => { + previousContextSettled(); + return response; + }); + } + return currentContextResponse.promise; + }, + ), + ); + + client = new ReflagClient({ + publishableKey: "test-key-opt-in-loading-context-race", + user: { id: "user1" }, + enableTracking: false, + bootstrappedFlags: { + optInFlag: { + key: "optInFlag", + isEnabled: false, + targetingVersion: 1, + }, + }, + }); + await client.initialize(); + + expect(client.getIsLoadingOptInFlags()).toBe(true); + await vi.waitFor(() => expect(httpClientGet).toHaveBeenCalledTimes(1)); + + const contextUpdate = client.setContext({ user: { id: "user2" } }); + await vi.waitFor(() => expect(httpClientGet).toHaveBeenCalledTimes(2)); + + previousContextResponse.resolve(optInEvaluationResponse(2, false)); + await vi.waitFor(() => expect(previousContextSettled).toHaveBeenCalled()); + expect(client.getIsLoadingOptInFlags()).toBe(true); + + currentContextResponse.resolve(optInEvaluationResponse(3, true)); + await contextUpdate; + + expect(client.getIsLoadingOptInFlags()).toBe(false); + expect(client.getContext().user?.id).toBe("user2"); + expect(client.getOptInFlags()[0]).toMatchObject({ + userOptedIn: true, + }); + }); + + it("uses newly applied bootstrapped metadata and ignores a stale refresh", async () => { + const staleResponse = deferred(); + const staleResponseSettled = vi.fn(); + server.use( + http.get("https://front.reflag.com/features/evaluated", () => + staleResponse.promise.then((response) => { + staleResponseSettled(); + return response; + }), + ), + ); + + client = new ReflagClient({ + publishableKey: "test-key-applied-bootstrap-opt-in-metadata", + user: { id: "user1" }, + enableTracking: false, + bootstrappedFlags: { + optInFlag: { + key: "optInFlag", + isEnabled: false, + targetingVersion: 1, + }, + }, + }); + await client.initialize(); + + expect(client.getIsLoadingOptInFlags()).toBe(true); + await vi.waitFor(() => expect(httpClientGet).toHaveBeenCalledTimes(1)); + + client.applyBootstrappedState({ + context: { user: { id: "user2" } }, + flags: optInFlags(true, 3), + flagStateVersion: 3, + }); + + expect(client.getIsLoadingOptInFlags()).toBe(false); + expect(client.getOptInFlags()[0]).toMatchObject({ + userOptedIn: true, + }); + + staleResponse.resolve(optInEvaluationResponse(2, false)); + await vi.waitFor(() => expect(staleResponseSettled).toHaveBeenCalled()); + await vi.waitFor(() => + expect(client["flagsClient"]["optInMetadataRefresh"]).toBeUndefined(), + ); + + expect(client.getIsLoadingOptInFlags()).toBe(false); + expect(client.getOptInFlags()[0]).toMatchObject({ + userOptedIn: true, + }); + }); + + it("does not leave missing opt-in metadata loading while offline", async () => { + client = new ReflagClient({ + publishableKey: "test-key-offline-bootstrap-opt-in-metadata", + user: { id: "user1" }, + enableTracking: false, + offline: true, + bootstrappedFlags: { + optInFlag: { + key: "optInFlag", + isEnabled: false, + targetingVersion: 1, + }, + }, + }); + + expect(client.getIsLoadingOptInFlags()).toBe(true); + await client.initialize(); + expect(client.getIsLoadingOptInFlags()).toBe(false); + expect(httpClientGet).not.toHaveBeenCalled(); + }); + it("waits for the bootstrapped flag state version when refreshing opt-in metadata", async () => { let requestedWaitForVersion: string | null = null; server.use( diff --git a/packages/browser-sdk/test/hooksManager.test.ts b/packages/browser-sdk/test/hooksManager.test.ts index 80044eca6..1496d3d5a 100644 --- a/packages/browser-sdk/test/hooksManager.test.ts +++ b/packages/browser-sdk/test/hooksManager.test.ts @@ -51,6 +51,15 @@ describe("HookManager", () => { expect(callback).toHaveBeenCalledWith(flags); }); + it("should add and trigger `optInFlagsLoadingUpdated` hooks", () => { + const callback = vi.fn(); + hookManager.addHook("optInFlagsLoadingUpdated", callback); + + hookManager.trigger("optInFlagsLoadingUpdated", true); + + expect(callback).toHaveBeenCalledWith(true); + }); + it("should add and trigger `track` hooks", () => { const callback = vi.fn(); const user: UserContext = { id: "user-id", name: "user-name" }; diff --git a/packages/react-sdk/README.md b/packages/react-sdk/README.md index 8b3f51724..109a3065b 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -552,7 +552,7 @@ The `` initializes the Reflag SDK, fetches flags and starts list - `staleTimeMs`: Maximum time (in milliseconds) that stale flags will be returned if `staleWhileRevalidate` is true and new flags cannot be fetched. - `offline`: Provide this option when testing or in local development environments to avoid contacting Reflag servers. - `loadingComponent` lets you specify an React component to be rendered instead of the children while the Reflag provider is initializing. If you want more control over loading screens, `useFlag()` and `useIsLoading` returns `isLoading` which you can use to customize the loading experience. -- `suspense`: Set to `true` to make `useFlag()` suspend while the provider is loading. Wrap components that call `useFlag()` in React `` boundaries and omit `loadingComponent` if you want Suspense fallbacks to control loading UI. +- `suspense`: Set to `true` to make `useFlag()` suspend while the provider is loading and `useOptInFlags()` suspend while required opt-in metadata is loading. Wrap components that call these hooks in React `` boundaries and omit `loadingComponent` if you want Suspense fallbacks to control loading UI. - `enableTracking`: Set to `false` to stop sending tracking events and user/company updates to Reflag. Useful when you're impersonating a user (defaults to `true`), - `enableLiveFlagUpdates`: Enables live flag updates over SSE. Defaults to `true` in the React SDK. - `apiBaseUrl`: Optional base URL for the Reflag API. This also controls the SSE origin used for live flag updates and automated feedback, @@ -602,7 +602,7 @@ function App({ bootstrapData }: AppProps) { > [!Note] > When using `ReflagBootstrappedProvider`, pass the entire object returned by `getFlagsForBootstrap()` directly as the `flags` prop. The context is extracted from `flags.context`, and `flags.flagStateVersion` is used when present. > -> 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. +> With `ReflagBootstrappedProvider`, `useOptInFlags()` triggers one flags refresh and returns `isLoading: true` (or suspends) until it settles. No refresh occurs unless the hook is used. > > If you want live flag updates to continue working after bootstrapping, use a recent `@reflag/node-sdk` so `getFlagsForBootstrap()` includes `flagStateVersion`. > @@ -685,10 +685,20 @@ Use these hooks to build an end-user opt-in UI for flags where opt-in is enabled import { useOptInFlags, useSetOptIn } from "@reflag/react-sdk"; function OptInList() { - const optInFlags = useOptInFlags(); + const { flags, isLoading } = useOptInFlags(); const setOptIn = useSetOptIn(); - return optInFlags.map((flag) => ( + // This is only true with ReflagBootstrappedProvider while the SDK fetches + // opt-in metadata on first use. + if (isLoading) { + return ; + } + + if (flags.length === 0) { + return

No opt-in flags are available.

; + } + + return flags.map((flag) => (