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/end-user-opt-in-sdk.md
Original file line number Diff line number Diff line change
@@ -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. Bootstrapped clients refresh missing browser opt-in metadata on demand when opt-in flags are requested.
45 changes: 32 additions & 13 deletions packages/browser-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -206,6 +206,33 @@ 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",
});
```

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.

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.
Expand Down Expand Up @@ -235,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

Expand Down Expand Up @@ -291,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");
```

Expand All @@ -303,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
Expand All @@ -327,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

Expand All @@ -336,16 +365,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()
Expand Down
208 changes: 204 additions & 4 deletions packages/browser-sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import {
CheckEvent,
FallbackFlagOverride,
FlagsClient,
OptInFlag,
RawFlags,
} from "./flag/flags";
import { isValidFlagStateVersion } from "./flag/flagStateVersion";
Expand Down Expand Up @@ -342,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;
Expand Down Expand Up @@ -419,6 +422,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.
Expand Down Expand Up @@ -470,6 +485,8 @@ 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 @@ -670,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
Expand Down Expand Up @@ -933,12 +953,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();
}
}

Expand Down Expand Up @@ -985,11 +1006,19 @@ 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) {
this.finishContextUpdate();
}

if (!contextChanged) {
Expand Down Expand Up @@ -1192,6 +1221,160 @@ export class ReflagClient {
return this.flagsClient.refreshFlags();
}

/**
* 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 [];

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<Response | undefined> {
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 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) }
: 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;
}

assertScopedContextUnchanged();
const refreshedFlags =
await this.flagsClient.refreshFlags(flagStateVersion);
assertScopedContextUnchanged();
if (!refreshedFlags) {
failConfirmation(
"Opt-in changed remotely, but the updated flag state could not be confirmed",
);
}

const refreshedOptIn = this.flagsClient.getFetchedFlags()[flagKey]?.optIn;
const scopedOptIn =
scope === "user"
? refreshedOptIn?.userOptedIn
: refreshedOptIn?.companyOptedIn;
const isConfirmed = options.optedIn
? scopedOptIn === true
: scopedOptIn !== true;
if (!isConfirmed) {
failConfirmation(
`Opt-in changed remotely, but the updated ${scope} membership was not reflected in the SDK`,
);
}

return res;
}

/**
* @deprecated Use `getFlag` instead.
*/
Expand Down Expand Up @@ -1282,6 +1465,23 @@ 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;

this.contextUpdateLoading = false;
if (this.state === "initializing") {
this.setState("initialized");
}
}

private setState(state: State) {
this.state = state;
this.hooks.trigger("stateUpdated", state);
Expand Down
Loading
Loading