Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/fuzzy-dodos-opt-in.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 7 additions & 3 deletions packages/browser-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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

Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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.
Expand Down
43 changes: 21 additions & 22 deletions packages/browser-sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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);
});
}

/**
Expand All @@ -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
Expand Down Expand Up @@ -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();
Expand All @@ -1012,9 +1014,7 @@ export class ReflagClient {
triggerEvent,
incomingFlagStateVersion,
);
if (this.optInFlagsRequested) {
void this.refreshOptInMetadataIfNeeded();
}
this.flagsClient.markBootstrappedStateApplied();
}

if (contextChanged) {
Expand Down Expand Up @@ -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 [];
Expand All @@ -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.
*/
Expand Down Expand Up @@ -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;

Expand Down
Loading
Loading