Skip to content
Open
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
8 changes: 8 additions & 0 deletions .changeset/quiet-flag-value-updates.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
"@reflag/browser-sdk": minor
"@reflag/react-sdk": minor
---

Add a per-flag `flagUpdated` event that publishes each initial flag value during initialization, including bootstrapped flags after applying cached overrides. Subsequent events fire only when the effective enabled/config value or flag presence changes. Include local overrides and config key/payload changes. The payload is `{ key, flag }`, with `flag` undefined on removal. Keep `flagsUpdated` unchanged for raw-data subscribers.

Subscribe React `useFlag()` to updates for its own key so unrelated flag updates and metadata-only changes do not trigger rerenders. Keep enabled/config reads and check telemetry consistent during concurrent React updates, while continuing to use the latest evaluation metadata for unchanged values.
57 changes: 40 additions & 17 deletions packages/browser-sdk/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -518,32 +518,55 @@ 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. 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.

Use the `on()` method to add an event listener to respond to certain events. See the API reference for details on each hook.
For most integrations, subscribe to `flagUpdated` to receive initial flag values and react to subsequent value changes. Use `on()` to register a listener:

```ts
import { ReflagClient, CheckEvent, RawFlags } from "@reflag/browser-sdk";
import { ReflagClient } from "@reflag/browser-sdk";

const client = new ReflagClient({
// options
publishableKey: "publishableKey",
});

// or add the hooks after construction:
const unsub = client.on("check", (check: CheckEvent) =>
console.log(`Check event ${check}`),
);
// use the returned function to unsubscribe, or call `off()` with the same arguments again
const unsub = client.on("flagUpdated", ({ key, flag }) => {
console.log(key, flag);
});

// Register before initializing to receive initial values, including bootstrapped flags.
await client.initialize();

// When disposing the integration, unsubscribe using the returned function,
// or call `off()` with the same event and handler.
unsub();
```

`flagUpdated` publishes an initial value for each flag during initialization. After that, it fires only when a flag's effective enabled value (including local overrides), config key/payload, or presence changes. The payload is `{ key, flag }`, with `flag` undefined on removal.

The `flag` field has the same raw shape as `getFlags()`; when present, its effective enabled value is `flag.isEnabledOverride ?? flag.isEnabled`. Accessing the event payload does not generate check telemetry.

Subscribe before calling `initialize()` to receive one initial event per flag, including disabled flags and bootstrapped flags. Bootstrapped flags are available immediately through `getFlags()`; their initial events are published during initialization after applying cached overrides, without fetching. Subscribers registered after initialization do not receive a replay.

Explicit silent updates (`updateFlags(flags, false)`) update the baseline without emitting events. Handle the `user` and `company` events separately if your integration needs to resynchronize traits when identity changes but flag values stay the same.

### Observing refreshed raw flag data

Use `flagsUpdated` when you need to observe raw flag updates, including refreshes where enabled/config values stay the same. It delivers the full flag map:

```ts
client.on("flagsUpdated", (flags) => {
console.log(flags);
});
```

A refresh returning identical raw flag data does not emit this event.

### Other events

- `check`: Your code used `isEnabled` or `config` for a flag
- `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.

## Zero PII

The Reflag Browser SDK doesn't collect any metadata and HTTP IP addresses are _not_ being stored.
Expand Down
3 changes: 3 additions & 0 deletions packages/browser-sdk/src/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -677,6 +677,9 @@ export class ReflagClient {
this.flagsClient.onUpdated(() => {
this.hooks.trigger("flagsUpdated", this.flagsClient.getFlags());
});
this.flagsClient.onFlagUpdated((event) => {
this.hooks.trigger("flagUpdated", event);
});
this.flagsClient.onOptInFlagsLoadingUpdated((isLoading) => {
this.hooks.trigger("optInFlagsLoadingUpdated", isLoading);
});
Expand Down
22 changes: 22 additions & 0 deletions packages/browser-sdk/src/flag/flagValueChanged.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import { deepEqual } from "fast-equals";

import type { RawFlag } from "./flags";

/**
* Compare effective values without reading telemetry-producing getters.
* @internal
*/
export function flagValueChanged(
previous: RawFlag | undefined,
next: RawFlag | undefined,
): boolean {
if (previous === next) return false;
if (!previous || !next) return true;

return (
(previous.isEnabledOverride ?? previous.isEnabled) !==
(next.isEnabledOverride ?? next.isEnabled) ||
previous.config?.key !== next.config?.key ||
!deepEqual(previous.config?.payload, next.config?.payload)
);
}
75 changes: 70 additions & 5 deletions packages/browser-sdk/src/flag/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import { logResponseError, parseResponseError } from "../utils/responseError";
import { retryOnThrow } from "../utils/retry";
import { FlagCache, isObject, parseAPIFlagsResponse } from "./flagCache";
import { isValidFlagStateVersion } from "./flagStateVersion";
import { flagValueChanged } from "./flagValueChanged";

const INITIAL_FETCH_RETRY_DELAYS_MS = [0, 5000];

Expand Down Expand Up @@ -150,6 +151,16 @@ export type RawFlag = {

export type RawFlags = Record<string, RawFlag>;

/**
* An initial flag value is published, its effective enabled/config value
* changed, or the flag was added or removed.
*/
export type FlagUpdatedEvent = {
key: string;
/** The current raw flag, or undefined when the flag was removed. */
flag: RawFlag | undefined;
};

export type FallbackFlagOverride =
| {
key: string;
Expand Down Expand Up @@ -309,6 +320,8 @@ export class FlagsClient {
private fetchedFlagsContextVersion = 0;
private flagOverrides: FlagOverrides = {};
private flags: RawFlags = {};
private flagUpdatedListeners = new Set<(event: FlagUpdatedEvent) => void>();
private pendingFlagUpdates: FlagUpdatedEvent[] | undefined;
private fallbackFlags: FallbackFlags = {};
private contextFetchVersion = 0;
private optInFlagsRequested = false;
Expand Down Expand Up @@ -385,17 +398,22 @@ export class FlagsClient {
this.flagOverrides = { ...cachedOverrides, ...this.flagOverrides };
}

if (!this.bootstrapped) {
if (this.bootstrapped) {
// Apply cached overrides silently, then publish each initial value once.
const previousFlags = this.flags;
this.updateFlags(false);
const rawFlagsChanged = previousFlags !== this.flags;
this.triggerFlagUpdates({});
if (rawFlagsChanged) this.triggerFlagsUpdated();
} else {
const requestContextVersion = this.contextFetchVersion;
this.applyFetchedFlagsResult(
await this.maybeFetchFlags(requestContextVersion),
true,
requestContextVersion,
);
this.updateFlags();
}

// Apply overrides and trigger update if flags have changed
this.updateFlags();
initializationSucceeded = true;
} finally {
this.initializationComplete = true;
Expand All @@ -416,6 +434,8 @@ export class FlagsClient {
public stop() {
this.supersedeOptInFlagsLoading(false);
this.abortController.abort();
this.flagUpdatedListeners.clear();
if (this.pendingFlagUpdates) this.pendingFlagUpdates.length = 0;
}

getFlags(): RawFlags {
Expand Down Expand Up @@ -634,8 +654,13 @@ export class FlagsClient {
const updatedFlags = this.mergeFlags(this.fetchedFlags, this.flagOverrides);
// Nothing has changed, skipping update
if (deepEqual(this.flags, updatedFlags)) return;
const previousFlags = this.flags;
this.flags = updatedFlags;
if (triggerEvent) this.triggerFlagsUpdated();
if (triggerEvent) {
// Publish values before raw-data subscribers can change overrides again.
this.triggerFlagUpdates(previousFlags);
this.triggerFlagsUpdated();
}
}

setFlagOverride(key: string, isEnabled: boolean | null) {
Expand Down Expand Up @@ -671,6 +696,17 @@ export class FlagsClient {
});
}

/**
* Register a callback for initial flag values and individual effective
* enabled/config value changes, including additions/removals.
* Metadata-only changes are ignored.
*/
onFlagUpdated(callback: (event: FlagUpdatedEvent) => void) {
if (!this.abortController.signal.aborted) {
this.flagUpdatedListeners.add(callback);
}
}

/**
* Send a flag "check" event.
*
Expand Down Expand Up @@ -1001,6 +1037,35 @@ export class FlagsClient {
return mergedFlags;
}

private triggerFlagUpdates(previousFlags: RawFlags) {
// Maps distinguish missing flags from inherited properties such as constructor.
const previous = new Map(Object.entries(previousFlags));
const current = new Map(Object.entries(this.flags));
const keys = new Set([...previous.keys(), ...current.keys()]);
const updates: FlagUpdatedEvent[] = [];
for (const key of keys) {
const flag = current.get(key);
if (flagValueChanged(previous.get(key), flag))
updates.push({ key, flag });
}

if (!updates.length) return;
if (this.pendingFlagUpdates) {
for (const update of updates) this.pendingFlagUpdates.push(update);
return;
}

this.pendingFlagUpdates = updates;
try {
// Array iteration visits appended events after the original batch finishes.
for (const update of updates) {
for (const callback of this.flagUpdatedListeners) callback(update);
}
} finally {
this.pendingFlagUpdates = undefined;
}
}

private triggerFlagsUpdated() {
this.eventTarget.dispatchEvent({ type: "flagsUpdated" });
}
Expand Down
10 changes: 9 additions & 1 deletion packages/browser-sdk/src/hooksManager.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { CompanyContext, UserContext } from "./context";
import { CheckEvent, RawFlags } from "./flag/flags";
import { CheckEvent, FlagUpdatedEvent, RawFlags } from "./flag/flags";

/**
* State of the client.
Expand All @@ -10,6 +10,12 @@ export interface HookArgs {
stateUpdated: State;
check: CheckEvent;
flagsUpdated: RawFlags;
/**
* Initial values are published during initialization, including bootstrapped flags.
* Subsequent events mean an effective enabled/config value or flag presence changed.
* Targeting versions, diagnostics and opt-in metadata do not trigger this event.
*/
flagUpdated: FlagUpdatedEvent;
optInFlagsLoadingUpdated: boolean;

/**
Expand Down Expand Up @@ -37,6 +43,7 @@ export class HooksManager {
stateUpdated: ((arg0: State) => void)[];
check: ((arg0: CheckEvent) => void)[];
flagsUpdated: ((arg0: RawFlags) => void)[];
flagUpdated: ((arg0: FlagUpdatedEvent) => void)[];
optInFlagsLoadingUpdated: ((arg0: boolean) => void)[];
user: ((arg0: UserContext) => void)[];
company: ((arg0: CompanyContext) => void)[];
Expand All @@ -45,6 +52,7 @@ export class HooksManager {
stateUpdated: [],
check: [],
flagsUpdated: [],
flagUpdated: [],
optInFlagsLoadingUpdated: [],
user: [],
company: [],
Expand Down
1 change: 1 addition & 0 deletions packages/browser-sdk/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,7 @@ export type {
CheckEvent,
FallbackFlagOverride,
FlagOverrides,
FlagUpdatedEvent,
OptInFlag,
RawFlag,
RawFlagOptIn,
Expand Down
Loading
Loading