This is page 5 of 7. Use http://codebase.md/bucketco/bucket-javascript-sdk?page={x} to view the full context.
# Directory Structure
```
├── .editorconfig
├── .gitattributes
├── .github
│ └── workflows
│ ├── package-ci.yml
│ └── publish.yml
├── .gitignore
├── .nvmrc
├── .prettierignore
├── .vscode
│ ├── extensions.json
│ └── settings.json
├── .yarnrc.yml
├── docs.sh
├── lerna.json
├── LICENSE
├── package.json
├── packages
│ ├── browser-sdk
│ │ ├── .prettierignore
│ │ ├── eslint.config.js
│ │ ├── example
│ │ │ ├── feedback
│ │ │ │ ├── feedback.html
│ │ │ │ └── Feedback.jsx
│ │ │ └── typescript
│ │ │ ├── app.ts
│ │ │ └── index.html
│ │ ├── FEEDBACK.md
│ │ ├── index.html
│ │ ├── package.json
│ │ ├── playwright.config.ts
│ │ ├── postcss.config.js
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── client.ts
│ │ │ ├── config.ts
│ │ │ ├── context.ts
│ │ │ ├── feedback
│ │ │ │ ├── feedback.ts
│ │ │ │ ├── prompts.ts
│ │ │ │ ├── promptStorage.ts
│ │ │ │ └── ui
│ │ │ │ ├── Button.css
│ │ │ │ ├── Button.tsx
│ │ │ │ ├── config
│ │ │ │ │ └── defaultTranslations.tsx
│ │ │ │ ├── css.d.ts
│ │ │ │ ├── FeedbackDialog.css
│ │ │ │ ├── FeedbackDialog.tsx
│ │ │ │ ├── FeedbackForm.css
│ │ │ │ ├── FeedbackForm.tsx
│ │ │ │ ├── hooks
│ │ │ │ │ └── useTimer.ts
│ │ │ │ ├── index.css
│ │ │ │ ├── index.ts
│ │ │ │ ├── Plug.tsx
│ │ │ │ ├── RadialProgress.css
│ │ │ │ ├── RadialProgress.tsx
│ │ │ │ ├── StarRating.css
│ │ │ │ ├── StarRating.tsx
│ │ │ │ └── types.ts
│ │ │ ├── flag
│ │ │ │ ├── flagCache.ts
│ │ │ │ └── flags.ts
│ │ │ ├── hooksManager.ts
│ │ │ ├── httpClient.ts
│ │ │ ├── index.ts
│ │ │ ├── logger.ts
│ │ │ ├── rateLimiter.ts
│ │ │ ├── sse.ts
│ │ │ ├── toolbar
│ │ │ │ ├── Flags.css
│ │ │ │ ├── Flags.tsx
│ │ │ │ ├── index.css
│ │ │ │ ├── index.ts
│ │ │ │ ├── Switch.css
│ │ │ │ ├── Switch.tsx
│ │ │ │ ├── Toolbar.css
│ │ │ │ └── Toolbar.tsx
│ │ │ └── ui
│ │ │ ├── constants.ts
│ │ │ ├── Dialog.css
│ │ │ ├── Dialog.tsx
│ │ │ ├── icons
│ │ │ │ ├── Check.tsx
│ │ │ │ ├── CheckCircle.tsx
│ │ │ │ ├── Close.tsx
│ │ │ │ ├── Dissatisfied.tsx
│ │ │ │ ├── Logo.tsx
│ │ │ │ ├── Neutral.tsx
│ │ │ │ ├── Satisfied.tsx
│ │ │ │ ├── VeryDissatisfied.tsx
│ │ │ │ └── VerySatisfied.tsx
│ │ │ ├── packages
│ │ │ │ └── floating-ui-preact-dom
│ │ │ │ ├── arrow.ts
│ │ │ │ ├── index.ts
│ │ │ │ ├── README.md
│ │ │ │ ├── types.ts
│ │ │ │ ├── useFloating.ts
│ │ │ │ └── utils
│ │ │ │ ├── deepEqual.ts
│ │ │ │ ├── getDPR.ts
│ │ │ │ ├── roundByDPR.ts
│ │ │ │ └── useLatestRef.ts
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ ├── test
│ │ │ ├── client.test.ts
│ │ │ ├── e2e
│ │ │ │ ├── acceptance.browser.spec.ts
│ │ │ │ ├── empty.html
│ │ │ │ ├── feedback-widget.browser.spec.ts
│ │ │ │ └── give-feedback-button.html
│ │ │ ├── flagCache.test.ts
│ │ │ ├── flags.test.ts
│ │ │ ├── hooksManager.test.ts
│ │ │ ├── httpClient.test.ts
│ │ │ ├── init.test.ts
│ │ │ ├── mocks
│ │ │ │ ├── handlers.ts
│ │ │ │ └── server.ts
│ │ │ ├── prompts.test.ts
│ │ │ ├── promptStorage.test.ts
│ │ │ ├── rateLimiter.test.ts
│ │ │ ├── sse.test.ts
│ │ │ ├── testLogger.ts
│ │ │ └── usage.test.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── typedoc.json
│ │ ├── vite.config.mjs
│ │ ├── vite.e2e.config.js
│ │ └── vitest.setup.ts
│ ├── cli
│ │ ├── .prettierignore
│ │ ├── commands
│ │ │ ├── apps.ts
│ │ │ ├── auth.ts
│ │ │ ├── flags.ts
│ │ │ ├── init.ts
│ │ │ ├── mcp.ts
│ │ │ ├── new.ts
│ │ │ └── rules.ts
│ │ ├── eslint.config.js
│ │ ├── index.ts
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── schema.json
│ │ ├── services
│ │ │ ├── bootstrap.ts
│ │ │ ├── flags.ts
│ │ │ ├── mcp.ts
│ │ │ └── rules.ts
│ │ ├── stores
│ │ │ ├── auth.ts
│ │ │ └── config.ts
│ │ ├── test
│ │ │ └── json.test.ts
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── utils
│ │ │ ├── auth.ts
│ │ │ ├── commander.ts
│ │ │ ├── constants.ts
│ │ │ ├── errors.ts
│ │ │ ├── file.ts
│ │ │ ├── gen.ts
│ │ │ ├── json.ts
│ │ │ ├── options.ts
│ │ │ ├── schemas.ts
│ │ │ ├── types.ts
│ │ │ ├── urls.ts
│ │ │ └── version.ts
│ │ └── vite.config.js
│ ├── eslint-config
│ │ ├── base.js
│ │ └── package.json
│ ├── flag-evaluation
│ │ ├── .prettierignore
│ │ ├── eslint.config.js
│ │ ├── jest.config.js
│ │ ├── package.json
│ │ ├── src
│ │ │ └── index.ts
│ │ ├── test
│ │ │ └── index.test.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ └── tsconfig.json
│ ├── node-sdk
│ │ ├── .prettierignore
│ │ ├── docs
│ │ │ ├── type-check-failed.png
│ │ │ └── type-check-payload-failed.png
│ │ ├── eslint.config.js
│ │ ├── examples
│ │ │ ├── cloudflare-worker
│ │ │ │ ├── .gitignore
│ │ │ │ ├── .prettierignore
│ │ │ │ ├── .vscode
│ │ │ │ │ └── settings.json
│ │ │ │ ├── package.json
│ │ │ │ ├── README.md
│ │ │ │ ├── src
│ │ │ │ │ └── index.ts
│ │ │ │ ├── tsconfig.json
│ │ │ │ ├── vitest.config.mts
│ │ │ │ ├── worker-configuration.d.ts
│ │ │ │ ├── wrangler.jsonc
│ │ │ │ └── yarn.lock
│ │ │ └── express
│ │ │ ├── app.test.ts
│ │ │ ├── app.ts
│ │ │ ├── bucket.ts
│ │ │ ├── bucketConfig.json
│ │ │ ├── package.json
│ │ │ ├── README.md
│ │ │ ├── serve.ts
│ │ │ ├── tsconfig.json
│ │ │ └── yarn.lock
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── batch-buffer.ts
│ │ │ ├── client.ts
│ │ │ ├── config.ts
│ │ │ ├── edgeClient.ts
│ │ │ ├── fetch-http-client.ts
│ │ │ ├── flusher.ts
│ │ │ ├── index.ts
│ │ │ ├── inRequestCache.ts
│ │ │ ├── periodicallyUpdatingCache.ts
│ │ │ ├── rate-limiter.ts
│ │ │ ├── types.ts
│ │ │ └── utils.ts
│ │ ├── test
│ │ │ ├── batch-buffer.test.ts
│ │ │ ├── client.test.ts
│ │ │ ├── config.test.ts
│ │ │ ├── fetch-http-client.test.ts
│ │ │ ├── flusher.test.ts
│ │ │ ├── inRequestCache.test.ts
│ │ │ ├── periodicallyUpdatingCache.test.ts
│ │ │ ├── rate-limiter.test.ts
│ │ │ ├── testConfig.json
│ │ │ └── utils.test.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── typedoc.json
│ │ └── vite.config.js
│ ├── openfeature-browser-provider
│ │ ├── .prettierignore
│ │ ├── eslint.config.js
│ │ ├── example
│ │ │ ├── .eslintrc.json
│ │ │ ├── .gitignore
│ │ │ ├── app
│ │ │ │ ├── featureManagement.ts
│ │ │ │ ├── globals.css
│ │ │ │ ├── layout.tsx
│ │ │ │ └── page.tsx
│ │ │ ├── components
│ │ │ │ ├── Context.tsx
│ │ │ │ ├── HuddleFeature.tsx
│ │ │ │ └── OpenFeatureProvider.tsx
│ │ │ ├── next.config.mjs
│ │ │ ├── package.json
│ │ │ ├── postcss.config.mjs
│ │ │ ├── README.md
│ │ │ ├── tailwind.config.ts
│ │ │ └── tsconfig.json
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── index.test.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ └── vite.config.js
│ ├── openfeature-node-provider
│ │ ├── .prettierignore
│ │ ├── eslint.config.js
│ │ ├── example
│ │ │ ├── app.ts
│ │ │ ├── package.json
│ │ │ ├── README.md
│ │ │ ├── reflag.ts
│ │ │ ├── serve.ts
│ │ │ ├── tsconfig.json
│ │ │ └── yarn.lock
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ ├── index.test.ts
│ │ │ └── index.ts
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ └── vite.config.js
│ ├── react-sdk
│ │ ├── .prettierignore
│ │ ├── dev
│ │ │ ├── .env
│ │ │ ├── nextjs-bootstrap-demo
│ │ │ │ ├── .eslintrc.json
│ │ │ │ ├── .gitignore
│ │ │ │ ├── app
│ │ │ │ │ ├── client.ts
│ │ │ │ │ ├── favicon.ico
│ │ │ │ │ ├── globals.css
│ │ │ │ │ ├── layout.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── components
│ │ │ │ │ └── Flags.tsx
│ │ │ │ ├── next.config.mjs
│ │ │ │ ├── package.json
│ │ │ │ ├── postcss.config.mjs
│ │ │ │ ├── public
│ │ │ │ │ ├── next.svg
│ │ │ │ │ └── vercel.svg
│ │ │ │ ├── README.md
│ │ │ │ ├── tailwind.config.ts
│ │ │ │ └── tsconfig.json
│ │ │ ├── nextjs-flag-demo
│ │ │ │ ├── .eslintrc.json
│ │ │ │ ├── .gitignore
│ │ │ │ ├── app
│ │ │ │ │ ├── favicon.ico
│ │ │ │ │ ├── globals.css
│ │ │ │ │ ├── layout.tsx
│ │ │ │ │ └── page.tsx
│ │ │ │ ├── components
│ │ │ │ │ ├── Flags.tsx
│ │ │ │ │ └── Providers.tsx
│ │ │ │ ├── next.config.mjs
│ │ │ │ ├── package.json
│ │ │ │ ├── postcss.config.mjs
│ │ │ │ ├── public
│ │ │ │ │ ├── next.svg
│ │ │ │ │ └── vercel.svg
│ │ │ │ ├── README.md
│ │ │ │ ├── tailwind.config.ts
│ │ │ │ └── tsconfig.json
│ │ │ └── plain
│ │ │ ├── app.tsx
│ │ │ ├── index.html
│ │ │ ├── index.tsx
│ │ │ ├── tsconfig.json
│ │ │ └── vite-env.d.ts
│ │ ├── eslint.config.js
│ │ ├── package.json
│ │ ├── README.md
│ │ ├── src
│ │ │ └── index.tsx
│ │ ├── test
│ │ │ └── usage.test.tsx
│ │ ├── tsconfig.build.json
│ │ ├── tsconfig.eslint.json
│ │ ├── tsconfig.json
│ │ ├── typedoc.json
│ │ └── vite.config.mjs
│ ├── tsconfig
│ │ ├── library.json
│ │ └── package.json
│ └── vue-sdk
│ ├── .prettierignore
│ ├── dev
│ │ └── plain
│ │ ├── App.vue
│ │ ├── components
│ │ │ ├── Events.vue
│ │ │ ├── FlagsList.vue
│ │ │ ├── MissingKeyMessage.vue
│ │ │ ├── RequestFeedback.vue
│ │ │ ├── Section.vue
│ │ │ ├── StartHuddlesButton.vue
│ │ │ └── Track.vue
│ │ ├── env.d.ts
│ │ ├── index.html
│ │ └── index.ts
│ ├── eslint.config.js
│ ├── package.json
│ ├── README.md
│ ├── src
│ │ ├── hooks.ts
│ │ ├── index.ts
│ │ ├── ReflagBootstrappedProvider.vue
│ │ ├── ReflagClientProvider.vue
│ │ ├── ReflagProvider.vue
│ │ ├── types.ts
│ │ ├── version.ts
│ │ └── vue.d.ts
│ ├── test
│ │ └── usage.test.ts
│ ├── tsconfig.build.json
│ ├── tsconfig.eslint.json
│ ├── tsconfig.json
│ ├── typedoc.json
│ └── vite.config.mjs
├── README.md
├── typedoc.json
├── vitest.workspace.js
└── yarn.lock
```
# Files
--------------------------------------------------------------------------------
/packages/browser-sdk/FEEDBACK.md:
--------------------------------------------------------------------------------
```markdown
# Reflag Feedback UI
The Reflag Browser SDK includes a UI you can use to collect feedback from user
about particular flags.

## Global feedback configuration
The Reflag Browser SDK feedback UI is configured with reasonable defaults,
positioning itself as a [dialog](#dialog) in the lower right-hand corner of
the viewport, displayed in English, and with a [light-mode theme](#custom-styling).
These settings can be overwritten when initializing the Reflag Browser SDK:
```typescript
const reflag = new ReflagClient({
publishableKey: "reflag-publishable-key",
user: { id: "42" },
feedback: {
ui: {
position: POSITION_CONFIG, // See positioning section
translations: TRANSLATION_KEYS, // See internationalization section
// Enable automated feedback surveys. Default: `true`
enableAutoFeedback: boolean,
/**
* Do your own feedback prompt handling or override
* default settings at runtime.
*/
autoFeedbackHandler: (promptMessage, handlers) => {
// See Automated Feedback Surveys section
},
},
},
});
```
See also:
- [Positioning and behavior](#positioning-and-behavior) for the position option,
- [Static language configuration](#static-language-configuration) if you want to translate the feedback UI,
- [Automated feedback surveys](#automated-feedback-surveys) to override default configuration.
## Automated feedback surveys
Automated feedback surveys are enabled by default.
When automated feedback surveys are enabled, the Reflag Browser SDK
will open and maintain a connection to the Reflag service. When a user
triggers an event tracked by a flag and is eligible to be prompted
for feedback, the Reflag service will send a request to the SDK instance.
By default, this request will open up the Reflag feedback UI in the user's
browser, but you can intercept the request and override this behavior.
The live connection for automated feedback is established when the
`ReflagClient` is initialized.
### Disabling automated feedback surveys
You can disable automated collection in the `ReflagClient` constructor:
```typescript
const reflag = new ReflagClient({
publishableKey: "reflag-publishable-key",
user: { id: "42" },
feedback: {
enableAutoFeedback: false,
},
});
```
### Overriding prompt event defaults
If you are not satisfied with the default UI behavior when an automated prompt
event arrives, you can can [override the global defaults](#global-feedback-configuration)
or intercept and override settings at runtime like this:
```javascript
const reflag = new ReflagClient({
publishableKey: "reflag-publishable-key",
user: { id: "42" },
feedback: {
autoFeedbackHandler: (promptMessage, handlers) => {
// Pass your overrides here. Everything is optional
handlers.openFeedbackForm({
title: promptMessage.question,
position: POSITION_CONFIG, // See positioning section
translations: TRANSLATION_KEYS, // See internationalization section
// Trigger side effects with the collected data,
// for example posting it back into your own CRM
onAfterSubmit: (feedback) => {
storeFeedbackInCRM({
score: feedback.score,
comment: feedback.comment,
});
},
});
},
},
});
```
See also:
- [Positioning and behavior](#positioning-and-behavior) for the position option.
- [Runtime language configuration](#runtime-language-configuration) if you want
to translate the feedback UI.
- [Use your own UI to collect feedback](#using-your-own-ui-to-collect-feedback) if
the feedback UI doesn't match your design.
## Manual feedback collection
To open up the feedback collection UI, call `reflagClient.requestFeedback(options)`
with the appropriate options. This approach is particularly beneficial if you wish
to retain manual control over feedback collection from your users while leveraging
the convenience of the Reflag feedback UI to reduce the amount of code you need
to maintain.
Examples of this could be if you want the click of a `give us feedback`-button
or the end of a specific user flow, to trigger a pop-up displaying the feedback
user interface.
### reflagClient.requestFeedback() options
Minimal usage with defaults:
```javascript
reflagClient.requestFeedback({
flagKey: "reflag-flag-key",
title: "How satisfied are you with file uploads?",
});
```
All options:
```javascript
reflagClient.requestFeedback({
flagKey: "reflag-flag-key", // [Required]
userId: "your-user-id", // [Optional] if user persistence is
// enabled (default in browsers),
companyId: "users-company-or-account-id", // [Optional]
title: "How satisfied are you with file uploads?" // [Optional]
position: POSITION_CONFIG, // [Optional] see the positioning section
translations: TRANSLATION_KEYS // [Optional] see the internationalization section
// [Optional] trigger side effects with the collected data,
// for example sending the feedback to your own CRM
onAfterSubmit: (feedback) => {
storeFeedbackInCRM({
score: feedback.score,
comment: feedback.comment
})
}
})
```
See also:
- [Positioning and behavior](#positioning-and-behavior) for the position option.
- [Runtime language configuration](#runtime-language-configuration) if
you want to translate the feedback UI.
## Positioning and behavior
The feedback UI can be configured to be placed and behave in 3 different ways:
### Positioning configuration
#### Modal
A modal overlay with a backdrop that blocks interaction with the underlying
page. It can be dismissed with the keyboard shortcut `<ESC>` or the dedicated
close button in the top right corner. It is always centered on the page, capturing
focus, and making it the primary interface the user needs to interact with.

Using a modal is the strongest possible push for feedback. You are interrupting the
user's normal flow, which can cause annoyance. A good use-case for the modal is
when the user finishes a linear flow that they don't perform often, for example
setting up a new account.
```javascript
position: {
type: "MODAL";
}
```
#### Dialog
A dialog that appears in a specified corner of the viewport, without limiting the
user's interaction with the rest of the page. It can be dismissed with the dedicated
close button, but will automatically disappear after a short time period if the user
does not interact with it.

Using a dialog is a soft push for feedback. It lets the user continue their work
with a minimal amount of intrusion. The user can opt-in to respond but is not
required to. A good use case for this behavior is when a user uses a flag where
the expected outcome is predictable, possibly because they have used it multiple
times before. For example: Uploading a file, switching to a different view of a
visualization, visiting a specific page, or manipulating some data.
The default feedback UI behavior is a dialog placed in the bottom right corner of
the viewport.
```typescript
position: {
type: "DIALOG";
placement: "top-left" | "top-right" | "bottom-left" | "bottom-right";
offset?: {
x?: string | number; // e.g. "-5rem", "10px" or 10 (pixels)
y?: string | number;
}
}
```
#### Popover
A popover that is anchored relative to a DOM-element (typically a button). It can
be dismissed by clicking outside the popover or by pressing the dedicated close button.

You can use the popover mode to implement your own button to collect feedback manually.
```typescript
type Position = {
type: "POPOVER";
anchor: DOMElement;
};
```
Popover feedback button example:
```html
<button id="feedbackButton">Tell us what you think</button>
<script>
const button = document.getElementById("feedbackButton");
button.addEventListener("click", (e) => {
reflagClient.requestFeedback({
flagKey: "reflag-flag-key",
userId: "your-user-id",
title: "How do you like the popover?",
position: {
type: "POPOVER",
anchor: e.currentTarget,
},
});
});
</script>
```
## Internationalization (i18n)
By default, the feedback UI is written in English. However, you can supply your own
translations by passing an object in the options to either or both of the
`new ReflagClient(options)` or `reflagClient.requestFeedback(options)` calls.
These translations will replace the English ones used by the feedback interface.
See examples below.

See [default English localization keys](https://github.com/reflagcom/javascript/tree/main/packages/browser-sdk/src/feedback/ui/config/defaultTranslations.tsx)
for a reference of what translation keys can be supplied.
### Static language configuration
If you know the language at page load, you can configure your translation keys while
initializing the Reflag Browser SDK:
```typescript
new ReflagClient({
publishableKey: "my-publishable-key",
feedback: {
ui: {
translations: {
DefaultQuestionLabel:
"Dans quelle mesure êtes-vous satisfait de cette fonctionnalité ?",
QuestionPlaceholder:
"Comment pouvons-nous améliorer cette fonctionnalité ?",
ScoreStatusDescription: "Choisissez une note et laissez un commentaire",
ScoreStatusLoading: "Chargement...",
ScoreStatusReceived: "La note a été reçue !",
ScoreVeryDissatisfiedLabel: "Très insatisfait",
ScoreDissatisfiedLabel: "Insatisfait",
ScoreNeutralLabel: "Neutre",
ScoreSatisfiedLabel: "Satisfait",
ScoreVerySatisfiedLabel: "Très satisfait",
SuccessMessage: "Merci d'avoir envoyé vos commentaires!",
SendButton: "Envoyer",
},
},
},
});
```
### Runtime language configuration
If you only know the user's language after the page has loaded, you can provide
translations to either the `reflagClient.requestFeedback(options)` call or
the `autoFeedbackHandler` option before the feedback interface opens.
See examples below.
```typescript
reflagClient.requestFeedback({
... // Other options
translations: {
// your translation keys
}
})
```
### Translations
When you are collecting feedback through the Reflag automation, you can intercept
the default prompt handling and override the defaults.
If you set the prompt question in the Reflag app to be one of your own translation
keys, you can even get a translated version of the question you want to ask your
customer in the feedback UI.
```javascript
new ReflagClient({
publishableKey: "reflag-publishable-key",
feedback: {
autoFeedbackHandler: (message, handlers) => {
const translatedQuestion =
i18nLookup[message.question] ?? message.question;
handlers.openFeedbackForm({
title: translatedQuestion,
translations: {
// your static translation keys
},
});
},
},
});
```
## Custom styling
You can adapt parts of the look of the Reflag feedback UI by applying CSS custom
properties to your page in your CSS `:root`-scope.
For example, a dark mode theme might look like this:

```css
:root {
--reflag-feedback-dialog-background-color: #1e1f24;
--reflag-feedback-dialog-color: rgba(255, 255, 255, 0.92);
--reflag-feedback-dialog-secondary-color: rgba(255, 255, 255, 0.3);
--reflag-feedback-dialog-border: rgba(255, 255, 255, 0.16);
--reflag-feedback-dialog-primary-button-background-color: #655bfa;
--reflag-feedback-dialog-primary-button-color: white;
--reflag-feedback-dialog-input-border-color: rgba(255, 255, 255, 0.16);
--reflag-feedback-dialog-input-focus-border-color: rgba(255, 255, 255, 0.3);
--reflag-feedback-dialog-error-color: #f56565;
--reflag-feedback-dialog-rating-1-color: #ed8936;
--reflag-feedback-dialog-rating-1-background-color: #7b341e;
--reflag-feedback-dialog-rating-2-color: #dd6b20;
--reflag-feedback-dialog-rating-2-background-color: #652b19;
--reflag-feedback-dialog-rating-3-color: #787c91;
--reflag-feedback-dialog-rating-3-background-color: #3e404c;
--reflag-feedback-dialog-rating-4-color: #38a169;
--reflag-feedback-dialog-rating-4-background-color: #1c4532;
--reflag-feedback-dialog-rating-5-color: #48bb78;
--reflag-feedback-dialog-rating-5-background-color: #22543d;
--reflag-feedback-dialog-submitted-check-background-color: #38a169;
--reflag-feedback-dialog-submitted-check-color: #ffffff;
}
```
Other examples of custom styling can be found in our [development example style-sheet](https://github.com/reflagcom/javascript/tree/main/packages/browser-sdk/src/feedback/ui/index.css).
## Using your own UI to collect feedback
You may have very strict design guidelines for your app and maybe the Reflag feedback
UI doesn't quite work for you. In this case, you can implement your own feedback
collection mechanism, which follows your own design guidelines. This is the data
type you need to collect:
```typescript
type DataToCollect = {
// Customer satisfaction score
score?: 1 | 2 | 3 | 4 | 5;
// The comment.
comment?: string;
};
```
Either `score` or `comment` must be defined in order to pass validation in the
Reflag API.
### Manual feedback collection with custom UI
Examples of a HTML-form that collects the relevant data can be found
in [feedback.html](https://github.com/reflagcom/javascript/tree/main/packages/browser-sdk/example/feedback/feedback.html) and [feedback.jsx](https://github.com/reflagcom/javascript/tree/main/packages/browser-sdk/example/feedback/Feedback.jsx).
Once you have collected the feedback data, pass it along to `reflagClient.feedback()`:
```javascript
reflagClient.feedback({
flagKey: "reflag-flag-key",
userId: "your-user-id",
score: 5,
comment: "Best thing I've ever tried!",
});
```
### Intercepting automated feedback survey events
When using automated feedback surveys, the Reflag service will, when specified,
send a feedback prompt message to your user's instance of the Reflag Browser SDK.
This will result in the feedback UI being opened.
You can intercept this behavior and open your own custom feedback collection form:
```typescript
new ReflagClient({
publishableKey: "reflag-publishable-key",
feedback: {
autoFeedbackHandler: async (promptMessage, handlers) => {
// This opens your custom UI
customFeedbackCollection({
// The question configured in the Reflag UI for the flag
question: promptMessage.question,
// When the user successfully submits feedback data.
// Use this instead of `reflagClient.feedback()`, otherwise
// the feedback prompt handler will keep being called
// with the same prompt message
onFeedbackSubmitted: (feedback) => {
handlers.reply(feedback);
},
// When the user closes the custom feedback form
// without leaving any response.
// It is important to feed this back, otherwise
// the feedback prompt handler will keep being called
// with the same prompt message
onFeedbackDismissed: () => {
handlers.reply(null);
},
});
},
},
});
```
```
--------------------------------------------------------------------------------
/packages/react-sdk/src/index.tsx:
--------------------------------------------------------------------------------
```typescript
"use client";
import React, {
createContext,
ReactNode,
useContext,
useEffect,
useMemo,
useState,
} from "react";
import {
CheckEvent,
CompanyContext,
HookArgs,
InitOptions,
RawFlag,
ReflagClient,
ReflagContext,
RequestFeedbackData,
TrackEvent,
UnassignedFeedback,
UserContext,
} from "@reflag/browser-sdk";
import { version } from "../package.json";
export type { CheckEvent, CompanyContext, TrackEvent, UserContext };
export type EmptyFlagRemoteConfig = { key: undefined; payload: undefined };
export type FlagType = {
config?: {
payload: any;
};
};
/**
* A remotely managed configuration value for a feature.
*/
export type FlagRemoteConfig =
| {
/**
* The key of the matched configuration value.
*/
key: string;
/**
* The optional user-supplied payload data.
*/
payload: any;
}
| EmptyFlagRemoteConfig;
/**
* Describes a feature
*/
export interface Flag<
TConfig extends FlagType["config"] = EmptyFlagRemoteConfig,
> {
/**
* The key of the feature.
*/
key: string;
/**
* If the feature is enabled.
*/
isEnabled: boolean;
/**
* If the feature is loading.
*/
isLoading: boolean;
/*
* Optional user-defined configuration.
*/
config:
| ({
key: string;
} & TConfig)
| EmptyFlagRemoteConfig;
/**
* Track feature usage in Reflag.
*/
track(): Promise<Response | undefined> | undefined;
/**
* Request feedback from the user.
*/
requestFeedback: (opts: RequestFeedbackOptions) => void;
}
// eslint-disable-next-line @typescript-eslint/no-empty-object-type
export interface Flags {}
/**
* Describes a collection of evaluated feature.
*
* @remarks
* This types falls back to a generic Record<string, Flag> if the Flags interface
* has not been extended.
*
*/
export type TypedFlags = keyof Flags extends never
? Record<string, Flag>
: {
[TypedFlagKey in keyof Flags]: Flags[TypedFlagKey] extends FlagType
? Flag<Flags[TypedFlagKey]["config"]>
: Flag;
};
export type FlagKey = keyof TypedFlags;
/**
* Describes a collection of evaluated raw flags.
*/
export type RawFlags = Record<FlagKey, RawFlag>;
export type BootstrappedFlags = {
context: ReflagContext;
flags: RawFlags;
};
const SDK_VERSION = `react-sdk/${version}`;
/**
* Base props for the ReflagProvider and ReflagBootstrappedProvider.
* @internal
*/
export type ReflagPropsBase = {
/**
* The children to render after the client is initialized.
*/
children?: ReactNode;
/**
* A React component to show while the client is initializing.
*/
loadingComponent?: ReactNode;
/**
* Set to `true` to show the loading component while the client is initializing.
*/
initialLoading?: boolean;
/**
* Set to `true` to enable debug logging to the console,
*/
debug?: boolean;
};
/**
* Base init options for the ReflagProvider and ReflagBootstrappedProvider.
* @internal
*/
export type ReflagInitOptionsBase = Omit<
InitOptions,
"user" | "company" | "other" | "otherContext" | "bootstrappedFlags"
>;
/**
* Map of clients by context key. Used to deduplicate initialization of the client.
* @internal
*/
const reflagClients = new Map<string, ReflagClient>();
/**
* Returns the ReflagClient for a given publishable key.
* Only creates a new ReflagClient is not already created or if it hook is run on the server.
* @internal
*/
function useReflagClient(initOptions: InitOptions, debug = false) {
const isServer = typeof window === "undefined";
if (isServer || !reflagClients.has(initOptions.publishableKey)) {
const client = new ReflagClient({
...initOptions,
logger: debug ? console : undefined,
sdkVersion: SDK_VERSION,
});
if (!isServer) {
reflagClients.set(initOptions.publishableKey, client);
}
return client;
}
return reflagClients.get(initOptions.publishableKey)!;
}
type ProviderContextType = {
isLoading: boolean;
client: ReflagClient;
};
const ProviderContext = createContext<ProviderContextType | null>(null);
/**
* Props for the ReflagClientProvider.
*/
export type ReflagClientProviderProps = Omit<ReflagPropsBase, "debug"> & {
client: ReflagClient;
};
export function ReflagClientProvider({
client,
loadingComponent,
initialLoading = true,
children,
}: ReflagClientProviderProps) {
const [isLoading, setIsLoading] = useState(
client.getState() !== "initialized" ? initialLoading : false,
);
useOnEvent(
"stateUpdated",
(state) => {
setIsLoading(state === "initializing");
},
client,
);
return (
<ProviderContext.Provider
value={{
isLoading,
client,
}}
>
{isLoading && typeof loadingComponent !== "undefined"
? loadingComponent
: children}
</ProviderContext.Provider>
);
}
/**
* Props for the ReflagProvider.
*/
export type ReflagProps = ReflagPropsBase &
ReflagInitOptionsBase & {
/**
* The context to use for the ReflagClient containing user, company, and other context.
*/
context?: ReflagContext;
/**
* Company related context. If you provide `id` Reflag will enrich the evaluation context with
* company attributes on Reflag servers.
* @deprecated Use `context` instead, this property will be removed in the next major version
*/
company?: CompanyContext;
/**
* User related context. If you provide `id` Reflag will enrich the evaluation context with
* user attributes on Reflag servers.
* @deprecated Use `context` instead, this property will be removed in the next major version
*/
user?: UserContext;
/**
* Context which is not related to a user or a company.
* @deprecated Use `context` instead, this property will be removed in the next major version
*/
otherContext?: Record<string, string | number | undefined>;
};
/**
* Provider for the ReflagClient.
*/
export function ReflagProvider({
children,
context,
user,
company,
otherContext,
loadingComponent,
initialLoading = true,
debug,
...config
}: ReflagProps) {
const resolvedContext = useMemo(
() => ({ user, company, other: otherContext, ...context }),
[user, company, otherContext, context],
);
const client = useReflagClient(
{
...config,
...resolvedContext,
},
debug,
);
// Initialize the client if it is not already initialized
useEffect(() => {
if (client.getState() !== "idle") return;
void client.initialize().catch((e) => {
client.logger.error("failed to initialize client", e);
});
}, [client]);
// Update the context if it changes
useEffect(() => {
void client.setContext(resolvedContext);
}, [client, resolvedContext]);
return (
<ReflagClientProvider
client={client}
initialLoading={initialLoading}
loadingComponent={loadingComponent}
>
{children}
</ReflagClientProvider>
);
}
/**
* Props for the ReflagBootstrappedProvider.
*/
export type ReflagBootstrappedProps = ReflagPropsBase &
ReflagInitOptionsBase & {
/**
* Pre-fetched flags to be used instead of fetching them from the server.
*/
flags: BootstrappedFlags;
};
/**
* Bootstrapped Provider for the ReflagClient using pre-fetched flags.
*/
export function ReflagBootstrappedProvider({
flags,
children,
loadingComponent,
initialLoading = false,
debug,
...config
}: ReflagBootstrappedProps) {
const client = useReflagClient(
{
...config,
...flags.context,
bootstrappedFlags: flags.flags,
},
debug,
);
// Initialize the client if it is not already initialized
useEffect(() => {
if (client.getState() !== "idle") return;
void client.initialize().catch((e) => {
client.logger.error("failed to initialize client", e);
});
}, [client]);
// Update the context if it changes on the client side
useEffect(() => {
void client.setContext(flags.context);
}, [client, flags.context]);
// Update the bootstrappedFlags if they change on the client side
useEffect(() => {
client.updateFlags(flags.flags);
}, [client, flags.flags]);
return (
<ReflagClientProvider
client={client}
initialLoading={initialLoading}
loadingComponent={loadingComponent}
>
{children}
</ReflagClientProvider>
);
}
export type RequestFeedbackOptions = Omit<
RequestFeedbackData,
"flagKey" | "featureId"
>;
/**
* @deprecated use `useFlag` instead
*/
export function useFeature<TKey extends FlagKey>(key: TKey) {
return useFlag(key);
}
/**
* Returns the state of a given feature for the current context, e.g.
*
* ```ts
* function HuddleButton() {
* const {isEnabled, config: { payload }, track} = useFlag("huddle");
* if (isEnabled) {
* return <button onClick={() => track()}>{payload?.buttonTitle ?? "Start Huddle"}</button>;
* }
* ```
*/
export function useFlag<TKey extends FlagKey>(key: TKey): TypedFlags[TKey] {
const client = useClient();
const isLoading = useIsLoading();
const [flag, setFlag] = useState(client.getFlag(key));
const track = () => client.track(key);
const requestFeedback = (opts: RequestFeedbackOptions) =>
client.requestFeedback({ ...opts, flagKey: key });
useOnEvent(
"flagsUpdated",
() => {
setFlag(client.getFlag(key));
},
client,
);
if (isLoading || !flag) {
return {
key,
isLoading,
isEnabled: false,
config: {
key: undefined,
payload: undefined,
} as TypedFlags[TKey]["config"],
track,
requestFeedback,
};
}
return {
key,
isLoading,
track,
requestFeedback,
get isEnabled() {
return flag.isEnabled ?? false;
},
get config() {
return flag.config as TypedFlags[TKey]["config"];
},
};
}
/**
* Returns a function to send an event when a user performs an action
* Note: When calling `useTrack`, user/company must already be set.
*
* ```ts
* const track = useTrack();
* track("Started Huddle", { button: "cta" });
* ```
*/
export function useTrack() {
const client = useClient();
return (eventName: string, attributes?: Record<string, any> | null) =>
client.track(eventName, attributes);
}
/**
* Returns a function to open up the feedback form
* Note: When calling `useRequestFeedback`, user/company must already be set.
*
* See [link](../../browser-sdk/FEEDBACK.md#reflagclientrequestfeedback-options) for more information
*
* ```ts
* const requestFeedback = useRequestFeedback();
* reflag.requestFeedback({
* flagKey: "file-uploads",
* title: "How satisfied are you with file uploads?",
* });
* ```
*/
export function useRequestFeedback() {
const client = useClient();
return (options: RequestFeedbackData) => client.requestFeedback(options);
}
/**
* Returns a function to manually send feedback collected from a user.
* Note: When calling `useSendFeedback`, user/company must already be set.
*
* See [link](./../../browser-sdk/FEEDBACK.md#using-your-own-ui-to-collect-feedback) for more information
*
* ```ts
* const sendFeedback = useSendFeedback();
* sendFeedback({
* flagKey: "huddle";
* question: "How did you like the new huddle feature?";
* score: 5;
* comment: "I loved it!";
* });
* ```
*/
export function useSendFeedback() {
const client = useClient();
return (opts: UnassignedFeedback) => client.feedback(opts);
}
/**
* Returns a function to update the current user's information.
* For example, if the user changed role or opted into a beta-feature.
*
* The method returned is a function which returns a promise that
* resolves when after the features have been updated as a result
* of the user update.
*
* ```ts
* const updateUser = useUpdateUser();
* updateUser({ optInHuddles: "true" }).then(() => console.log("Flags updated"));
* ```
*/
export function useUpdateUser() {
const client = useClient();
return (opts: { [key: string]: string | number | undefined }) =>
client.updateUser(opts);
}
/**
* Returns a function to update the current company's information.
* For example, if the company changed plan or opted into a beta-feature.
*
* The method returned is a function which returns a promise that
* resolves when after the features have been updated as a result
* of the company update.
*
* ```ts
* const updateCompany = useUpdateCompany();
* updateCompany({ plan: "enterprise" }).then(() => console.log("Flags updated"));
* ```
*/
export function useUpdateCompany() {
const client = useClient();
return (opts: { [key: string]: string | number | undefined }) =>
client.updateCompany(opts);
}
/**
* Returns a function to update the "other" context information.
* For example, if the user changed workspace, you can set the workspace id here.
*
* The method returned is a function which returns a promise that
* resolves when after the features have been updated as a result
* of the update to the "other" context.
*
* ```ts
* const updateOtherContext = useUpdateOtherContext();
* updateOtherContext({ workspaceId: newWorkspaceId })
* .then(() => console.log("Flags updated"));
* ```
*/
export function useUpdateOtherContext() {
const client = useClient();
return (opts: { [key: string]: string | number | undefined }) =>
client.updateOtherContext(opts);
}
/**
* Returns the current `ReflagProvider` context.
* @internal
*/
function useSafeContext() {
const ctx = useContext(ProviderContext);
if (!ctx) {
throw new Error(
`ReflagProvider is missing. Please ensure your component is wrapped with a ReflagProvider.`,
);
}
return ctx;
}
/**
* Returns a boolean indicating if the Reflag client is loading.
* You can use this to check if the Reflag client is loading at any point in your application.
* Initially, the value will be true until the client is initialized.
*
* @example
* ```ts
* import { useIsLoading } from '@reflag/react-sdk';
*
* const isLoading = useIsLoading();
*
* console.log(isLoading);
* ```
*
* @returns A boolean indicating if the Reflag client is loading.
*/
export function useIsLoading() {
const context = useSafeContext();
return context.isLoading;
}
/**
* Returns the current `ReflagClient` used by the `ReflagProvider`.
*
* This is useful if you need to access the `ReflagClient` outside of the `ReflagProvider`.
*
* @example
* ```ts
* import { useClient } from '@reflag/react-sdk';
*
* function App() {
* const client = useClient();
* console.log(client.getContext());
* }
* ```
*
* @returns The `ReflagClient`.
*/
export function useClient() {
const context = useSafeContext();
return context.client;
}
/**
* Attach a callback handler to client events to act on changes. It automatically disposes itself on unmount.
*
* @example
* ```ts
* import { useOnEvent } from '@reflag/react-sdk';
*
* useOnEvent("flagsUpdated", () => {
* console.log("flags updated");
* });
* ```
*
* @param event - The event to listen to.
* @param handler - The function to call when the event is triggered.
* @param client - The Reflag client to listen to. If not provided, the client will be retrieved from the context.
*/
export function useOnEvent<THookType extends keyof HookArgs>(
event: THookType,
handler: (arg0: HookArgs[THookType]) => void,
client?: ReflagClient,
) {
const contextClient = useContext(ProviderContext);
const resolvedClient = client ?? contextClient?.client;
if (!resolvedClient) {
throw new Error(
`ReflagProvider is missing and no client was provided. Please ensure your component is wrapped with a ReflagProvider.`,
);
}
useEffect(() => {
return resolvedClient.on(event, handler);
}, [resolvedClient, event, handler]);
}
```
--------------------------------------------------------------------------------
/packages/node-sdk/src/types.ts:
--------------------------------------------------------------------------------
```typescript
/* eslint-disable @typescript-eslint/no-empty-object-type */
import { newEvaluator, RuleFilter } from "@reflag/flag-evaluation";
/**
* Describes the meta context associated with tracking.
**/
export type TrackingMeta = {
/**
* Whether the user or company is active.
**/
active?: boolean;
};
/**
* Describes the attributes of a user, company or event.
**/
export type Attributes = Record<string, any>;
/**
* Describes a flag event. Can be "check" or "check-config event".
**/
export type FlagEvent = {
/**
* The action that was performed.
**/
action: "check" | "check-config";
/**
* The flag key.
**/
key: string;
/**
* The feature targeting version (optional).
**/
targetingVersion: number | undefined;
/**
* The result of targeting evaluation.
**/
evalResult:
| boolean
| { key: string; payload: any }
| { key: undefined; payload: undefined };
/**
* The context that was used for evaluation.
**/
evalContext?: Record<string, any>;
/**
* The result of evaluation of each rule (optional).
**/
evalRuleResults?: boolean[];
/**
* The missing fields in the evaluation context (optional).
**/
evalMissingFields?: string[];
};
/**
* A remotely managed configuration value for a feature.
*/
export type RawFlagRemoteConfig = {
/**
* The key of the matched configuration value.
*/
key: string;
/**
* The version of the targeting rules used to select the config value.
*/
targetingVersion?: number;
/**
* The optional user-supplied payload data.
*/
payload: any;
/**
* The rule results of the evaluation (optional).
*/
ruleEvaluationResults?: boolean[];
/**
* The missing fields in the evaluation context (optional).
*/
missingContextFields?: string[];
};
/**
* Describes a feature.
*/
export interface RawFlag {
/**
* The key of the feature.
*/
key: string;
/**
* If the feature is enabled.
*/
isEnabled: boolean;
/**
* The version of the targeting used to evaluate if the feature is enabled (optional).
*/
targetingVersion?: number;
/**
* The remote configuration value for the feature.
*/
config?: RawFlagRemoteConfig;
/**
* The rule results of the evaluation (optional).
*/
ruleEvaluationResults?: boolean[];
/**
* The missing fields in the evaluation context (optional).
*/
missingContextFields?: string[];
}
/**
* Describes a collection of evaluated raw flags.
*/
export type RawFlags = Record<TypedFlagKey, RawFlag>;
/**
* Describes a collection of evaluated raw flags and the context for bootstrapping.
*/
export type BootstrappedFlags = {
context: Context;
flags: RawFlags;
};
export type EmptyFlagRemoteConfig = { key: undefined; payload: undefined };
/**
* A remotely managed configuration value for a feature.
*/
export type FlagRemoteConfig =
| {
/**
* The key of the matched configuration value.
*/
key: string;
/**
* The optional user-supplied payload data.
*/
payload: any;
}
| EmptyFlagRemoteConfig;
/**
* Describes a feature
*/
export interface Flag<
TConfig extends FlagType["config"] = EmptyFlagRemoteConfig,
> {
/**
* The key of the feature.
*/
key: string;
/**
* If the feature is enabled.
*/
isEnabled: boolean;
/*
* Optional user-defined configuration.
*/
config:
| ({
key: string;
} & TConfig)
| EmptyFlagRemoteConfig;
/**
* Track feature usage in Reflag.
*/
track(): Promise<void>;
}
export type FlagType = {
config?: {
payload: any;
};
};
export type FlagOverride =
| (FlagType & {
isEnabled: boolean;
config?: {
key: string;
};
})
| boolean;
/**
* Describes a feature definition.
*/
export type FlagDefinition = {
/**
* The key of the feature.
*/
key: string;
/**
* Description of the feature.
*/
description: string | null;
/**
* The targeting rules for the feature.
*/
flag: {
/**
* The version of the targeting rules.
*/
version: number;
/**
* The targeting rules.
*/
rules: {
/**
* The filter for the rule.
*/
filter: RuleFilter;
}[];
};
/**
* The remote configuration for the feature.
*/
config?: {
/**
* The version of the remote configuration.
*/
version: number;
/**
* The variants of the remote configuration.
*/
variants: FlagConfigVariant[];
};
};
/**
* Describes a collection of evaluated features.
*
* @remarks
* You should extend the Flags interface to define the available features.
*/
export interface Flags {}
/**
* Describes a collection of evaluated feature.
*
* @remarks
* This types falls back to a generic Record<string, Flag> if the Flags interface
* has not been extended.
*
*/
export type TypedFlags = keyof Flags extends never
? Record<string, Flag>
: {
[FlagKey in keyof Flags]: Flags[FlagKey] extends FlagType
? Flag<Flags[FlagKey]["config"]>
: Flag;
};
export type TypedFlagKey = keyof TypedFlags;
/**
* Describes the feature overrides.
*/
export type FlagOverrides = Partial<
keyof Flags extends never
? Record<string, FlagOverride>
: {
[FlagKey in keyof Flags]: Flags[FlagKey] extends FlagOverride
? Flags[FlagKey]
: Exclude<FlagOverride, "config">;
}
>;
export type FlagOverridesFn = (context: Context) => FlagOverrides;
/**
* Describes a remote feature config variant.
*/
export type FlagConfigVariant = {
/**
* The filter for the variant.
*/
filter: RuleFilter;
/**
* The optional user-supplied payload data.
*/
payload: any;
/**
* The key of the variant.
*/
key: string;
};
/**
* (Internal) Describes a specific feature in the API response.
*
* @internal
*/
export type FlagAPIResponse = {
/**
* The key of the feature.
*/
key: string;
/**
* Description of the feature.
*/
description: string | null;
/**
* The targeting rules for the feature.
*/
targeting: {
/**
* The version of the targeting rules.
*/
version: number;
/**
* The targeting rules.
*/
rules: {
/**
* The filter for the rule.
*/
filter: RuleFilter;
}[];
};
/**
* The remote configuration for the feature.
*/
config?: {
/**
* The version of the remote configuration.
*/
version: number;
/**
* The variants of the remote configuration.
*/
variants: FlagConfigVariant[];
};
};
/**
* (Internal) Describes the response of the features endpoint.
*
* @internal
*/
export type FlagsAPIResponse = {
/**
* The feature definitions.
*/
features: FlagAPIResponse[];
};
/**
* (Internal) Flag definitions with the addition of a pre-prepared
* evaluators functions for the rules.
*
* @internal
*/
export type CachedFlagDefinition = FlagAPIResponse & {
enabledEvaluator: ReturnType<typeof newEvaluator<boolean>>;
configEvaluator: ReturnType<typeof newEvaluator<any>> | undefined;
};
/**
* (Internal) Describes the response of the evaluated features endpoint.
*
* @internal
*/
export type EvaluatedFlagsAPIResponse = {
/**
* True if request successful.
*/
success: true;
/**
* True if additional context for user or company was found and used for evaluation on the remote server.
*/
remoteContextUsed: boolean;
/**
* The feature definitions.
*/
features: RawFlags;
};
/**
* Describes the response of a HTTP client.
*
* @typeParam TResponse - The type of the response body.
*/
export type HttpClientResponse<TResponse> = {
/**
* The status code of the response.
**/
status: number;
/**
* Indicates that the request succeeded.
**/
ok: boolean;
/**
* The body of the response if available.
**/
body: TResponse | undefined;
};
/**
* Defines the interface for an HTTP client.
*
* @remarks
* This interface is used to abstract the HTTP client implementation from the SDK.
* Define your own implementation of this interface to use a different HTTP client.
**/
export interface HttpClient {
/**
* Sends a POST request to the specified URL.
*
* @param url - The URL to send the request to.
* @param headers - The headers to include in the request.
* @param body - The body of the request.
* @returns The response from the server.
**/
post<TBody, TResponse>(
url: string,
headers: Record<string, string>,
body: TBody,
): Promise<HttpClientResponse<TResponse>>;
/**
* Sends a GET request to the specified URL.
*
* @param url - The URL to send the request to.
* @param headers - The headers to include in the request.
* @returns The response from the server.
**/
get<TResponse>(
url: string,
headers: Record<string, string>,
timeoutMs: number,
): Promise<HttpClientResponse<TResponse>>;
}
/**
* Logger interface for logging messages
*/
export interface Logger {
/**
* Log a debug messages
*
* @param message - The message to log
* @param data - Optional data to log
*/
debug: (message: string, data?: any) => void;
/**
* Log an info messages
*
* @param message - The message to log
* @param data - Optional data to log
*/
info: (message: string, data?: any) => void;
/**
* Log a warning messages
*
* @param message - The message to log
* @param data - Optional data to log
*/
warn: (message: string, data?: any) => void;
/**
* Log an error messages
*
* @param message - The message to log
* @param data - Optional data to log
*/
error: (message: string, data?: any) => void;
}
/**
* A cache for storing values.
*
* @typeParam T - The type of the value.
**/
export type Cache<T> = {
/**
* Get the value.
* @returns The value or `undefined` if the value is not available.
**/
get: () => T | undefined;
/**
* Refresh the value immediately and return it, or `undefined` if the value is not available.
*
* @returns The value or `undefined` if the value is not available.
**/
refresh: () => Promise<T | undefined>;
/**
* If a refresh is in progress, wait for it to complete.
*
* @returns A promise that resolves when the refresh is complete.
**/
waitRefresh: () => Promise<void> | undefined;
/**
* Cleanup and destroy the cache, stopping any background processes.
**/
destroy: () => void;
};
/**
* Options for configuring the BatchBuffer.
*
* @template T - The type of items in the buffer.
*/
export type BatchBufferOptions<T> = {
/**
* A function that handles flushing the items in the buffer.
**/
flushHandler: (items: T[]) => Promise<void>;
/**
* The logger to use for logging (optional).
**/
logger?: Logger;
/**
* The maximum size of the buffer before it is flushed.
*
* @defaultValue `100`
**/
maxSize?: number;
/**
* The interval in milliseconds at which the buffer is flushed.
* @remarks
* If `0`, the buffer is flushed only when `maxSize` is reached.
* @defaultValue `1000`
**/
intervalMs?: number;
/**
* Whether to flush the buffer on exit.
*
* @defaultValue `true`
*/
flushOnExit?: boolean;
};
export type CacheStrategy = "periodically-update" | "in-request";
/**
* Defines the options for the SDK client.
*
**/
export type ClientOptions = {
/**
* The secret key used to authenticate with the Reflag API.
**/
secretKey?: string;
/**
* @deprecated
* Use `apiBaseUrl` instead.
**/
host?: string;
/**
* The host to send requests to (optional).
**/
apiBaseUrl?: string;
/**
* The logger to use for logging (optional). Default is info level logging to console.
**/
logger?: Logger;
/**
* Use the console logger, but set a log level. Ineffective if a custom logger is provided.
**/
logLevel?: LogLevel;
/**
* The features to "enable" as fallbacks when the API is unavailable (optional).
* Can be an array of feature keys, or a record of feature keys and boolean or object values.
*
* If a record is supplied instead of array, the values of each key are either the
* configuration values or the boolean value `true`.
**/
fallbackFlags?:
| TypedFlagKey[]
| Record<TypedFlagKey, Exclude<FlagOverride, false>>;
/**
* The HTTP client to use for sending requests (optional). Default is the built-in fetch client.
**/
httpClient?: HttpClient;
/**
* The timeout in milliseconds for fetching feature targeting data (optional).
* Default is 10000 ms.
**/
fetchTimeoutMs?: number;
/**
* Number of times to retry fetching feature definitions (optional).
* Default is 3 times.
**/
flagsFetchRetries?: number;
/**
* The options for the batch buffer (optional).
* If not provided, the default options are used.
**/
batchOptions?: Omit<BatchBufferOptions<any>, "flushHandler" | "logger">;
/**
* If a filename is specified, feature targeting results be overridden with
* the values from this file. The file should be a JSON object with flag
* keys as keys, and boolean or object as values.
*
* If a function is specified, the function will be called with the context
* and should return a record of flag keys and boolean or object values.
*
* Defaults to "reflagFlags.json".
**/
flagOverrides?: string | ((context: Context) => FlagOverrides);
/**
* In offline mode, no data is sent or fetched from the the Reflag API.
* This is useful for testing or development.
*/
offline?: boolean;
/**
* If set to `false`, no evaluation events will be emitted.
*/
emitEvaluationEvents?: boolean;
/**
* The path to the config file. If supplied, the config file will be loaded.
* Defaults to `reflag.config.json` when NODE_ENV is not production. Can also be
* set through the environment variable REFLAG_CONFIG_FILE.
*/
configFile?: string;
/**
* The cache strategy to use for the client (optional, defaults to "periodically-update").
**/
cacheStrategy?: CacheStrategy;
};
/**
* Defines the options for tracking of entities.
*
**/
export type TrackOptions = {
/**
* The attributes associated with the event.
**/
attributes?: Attributes;
/**
* The meta context associated with the event.
**/
meta?: TrackingMeta;
};
/**
* Describes the current user context, company context, and other context.
* This is used to determine if feature targeting matches and to track events.
**/
export type Context = {
/**
* The user context. If no `id` key is set, the whole object is ignored.
*/
user?: {
/**
* The identifier of the user.
*/
id: string | number | undefined;
/**
* The name of the user.
*/
name?: string | undefined;
/**
* The email of the user.
*/
email?: string | undefined;
/**
* The avatar URL of the user.
*/
avatar?: string | undefined;
/**
* Custom attributes of the user.
*/
[k: string]: any;
};
/**
* The company context. If no `id` key is set, the whole object is ignored.
*/
company?: {
/**
* The identifier of the company.
*/
id: string | number | undefined;
/**
* The name of the company.
*/
name?: string | undefined;
/**
* The avatar URL of the company.
*/
avatar?: string | undefined;
/**
* Custom attributes of the company.
*/
[k: string]: any;
};
/**
* The other context. This is used for any additional context that is not related to user or company.
*/
other?: Record<string, any>;
};
/**
* A context with tracking option.
**/
export interface ContextWithTracking extends Context {
/**
* Enable tracking for the context.
* If set to `false`, tracking will be disabled for the context. Default is `true`.
*/
enableTracking?: boolean;
/**
* The meta context used to update the user or company when syncing is required during
* feature retrieval.
*/
meta?: TrackingMeta;
}
export const LOG_LEVELS = ["DEBUG", "INFO", "WARN", "ERROR"] as const;
export type LogLevel = (typeof LOG_LEVELS)[number];
export type IdType = string | number;
```
--------------------------------------------------------------------------------
/packages/browser-sdk/test/usage.test.ts:
--------------------------------------------------------------------------------
```typescript
import { http, HttpResponse } from "msw";
import {
afterEach,
beforeAll,
beforeEach,
describe,
expect,
it,
test,
vi,
} from "vitest";
import { ReflagClient } from "../src";
import { API_BASE_URL } from "../src/config";
import { FeedbackPromptHandler } from "../src/feedback/feedback";
import {
checkPromptMessageCompleted,
getAuthToken,
markPromptMessageCompleted,
} from "../src/feedback/promptStorage";
import { FlagsClient } from "../src/flag/flags";
import { HttpClient } from "../src/httpClient";
import {
AblySSEChannel,
closeAblySSEChannel,
openAblySSEChannel,
} from "../src/sse";
import { flagsResult } from "./mocks/handlers";
import { server } from "./mocks/server";
const KEY = "123";
vi.mock("../src/sse");
vi.mock("../src/feedback/promptStorage", () => {
return {
markPromptMessageCompleted: vi.fn(),
checkPromptMessageCompleted: vi.fn(),
rememberAuthToken: vi.fn(),
getAuthToken: vi.fn(),
};
});
// Treat test environment as desktop
window.innerWidth = 1024;
afterEach(() => {
server.resetHandlers();
});
describe("usage", () => {
afterEach(() => {
vi.clearAllMocks();
});
test("golden path - register `user`, `company`, send `event`, send `feedback`, get `flags`", async () => {
const reflagInstance = new ReflagClient({
publishableKey: KEY,
user: { id: "foo " },
company: { id: "bar", name: "bar corp" },
});
await reflagInstance.initialize();
await reflagInstance.track("baz", { baz: true });
await reflagInstance.feedback({
flagKey: "huddles",
score: 5,
comment: "Sunt bine!",
question: "Cum esti?",
promptedQuestion: "How are you?",
});
const flags = reflagInstance.getFlags();
expect(flags).toEqual(flagsResult);
const flag = reflagInstance.getFlag("flag-1");
expect(flag).toStrictEqual({
isEnabled: false,
track: expect.any(Function),
requestFeedback: expect.any(Function),
config: { key: undefined, payload: undefined },
isEnabledOverride: null,
setIsEnabledOverride: expect.any(Function),
});
});
test("accepts `flagKey` instead of `featureId` for manual feedback", async () => {
const reflagInstance = new ReflagClient({
publishableKey: KEY,
user: { id: "foo" },
company: { id: "bar" },
});
await reflagInstance.initialize();
await reflagInstance.feedback({
flagKey: "flag-key",
score: 5,
question: "What's up?",
promptedQuestion: "How are you?",
});
});
});
// TODO:
// Since we now have AutoFeedback as it's own class, we should rewrite these tests
// to test that class instead of the ReflagClient class.
// Same for feedback state management below
describe("feedback prompting", () => {
const closeChannel = vi.fn();
beforeAll(() => {
vi.mocked(openAblySSEChannel).mockReturnValue({
close: closeChannel,
} as unknown as AblySSEChannel);
vi.mocked(closeAblySSEChannel).mockResolvedValue(undefined);
});
afterEach(() => {
vi.clearAllMocks();
vi.mocked(getAuthToken).mockReturnValue(undefined);
});
test("initiates and stops feedback prompting", async () => {
const reflagInstance = new ReflagClient({
publishableKey: KEY,
user: { id: "foo" },
});
await reflagInstance.initialize();
expect(openAblySSEChannel).toBeCalledTimes(1);
// call twice, expect only one reset to go through
await reflagInstance.stop();
await reflagInstance.stop();
expect(closeChannel).toBeCalledTimes(1);
});
test("does not call tracking endpoints if token cached", async () => {
const specialChannel = "special-channel";
vi.mocked(getAuthToken).mockReturnValue({
channel: specialChannel,
token: "something",
});
server.use(
http.post(`${API_BASE_URL}/feedback/prompting-init`, () => {
throw new Error("should not be called");
}),
);
const reflagInstance = new ReflagClient({
publishableKey: KEY,
user: { id: "foo" },
});
await reflagInstance.initialize();
expect(openAblySSEChannel).toBeCalledTimes(1);
const args = vi.mocked(openAblySSEChannel).mock.calls[0][0];
expect(args.channel).toBe(specialChannel);
expect(args.userId).toBe("foo");
});
test("does not initiate feedback prompting if server does not agree", async () => {
server.use(
http.post(`${API_BASE_URL}/feedback/prompting-init`, () => {
return HttpResponse.json({ success: false });
}),
);
const reflagInstance = new ReflagClient({
publishableKey: KEY,
user: { id: "foo" },
});
await reflagInstance.initialize();
expect(openAblySSEChannel).toBeCalledTimes(0);
});
test("skip feedback prompting if no user id configured", async () => {
const reflagInstance = new ReflagClient({ publishableKey: KEY });
await reflagInstance.initialize();
expect(openAblySSEChannel).toBeCalledTimes(0);
});
test("skip feedback prompting if automated feedback surveys are disabled", async () => {
const reflagInstance = new ReflagClient({
publishableKey: KEY,
user: { id: "foo" },
feedback: { enableAutoFeedback: false },
});
await reflagInstance.initialize();
expect(openAblySSEChannel).toBeCalledTimes(0);
});
});
describe("feedback state management", () => {
const message = {
question: "How are you?",
showAfter: new Date(Date.now() - 10000).valueOf(),
showBefore: new Date(Date.now() + 10000).valueOf(),
promptId: "123",
featureId: "456",
};
let events: string[] = [];
let reflagInstance: ReflagClient | null = null;
beforeEach(() => {
vi.mocked(openAblySSEChannel).mockImplementation(({ callback }) => {
callback(message);
// eslint-disable-next-line @typescript-eslint/no-empty-function
return { close: () => {} } as AblySSEChannel;
});
events = [];
server.use(
http.post(
`${API_BASE_URL}/feedback/prompt-events`,
async ({ request }) => {
const body = await request.json();
if (!(body && typeof body === "object" && "action" in body)) {
throw new Error("invalid request");
}
events.push(String(body["action"]));
return HttpResponse.json({ success: true });
},
),
);
});
afterEach(async () => {
if (reflagInstance) await reflagInstance.stop();
vi.resetAllMocks();
});
const createReflagInstance = async (callback: FeedbackPromptHandler) => {
reflagInstance = new ReflagClient({
publishableKey: KEY,
user: { id: "foo" },
feedback: {
autoFeedbackHandler: callback,
},
});
await reflagInstance.initialize();
return reflagInstance;
};
test("ignores prompt if expired", async () => {
vi.useFakeTimers();
vi.setSystemTime(message.showAfter - 10000);
const callback = vi.fn();
await createReflagInstance(callback);
expect(callback).not.toHaveBeenCalled();
expect(markPromptMessageCompleted).not.toHaveBeenCalledOnce();
vi.clearAllTimers();
vi.useRealTimers();
});
test("ignores prompt if already seen", async () => {
vi.mocked(checkPromptMessageCompleted).mockReturnValue(true);
expect(checkPromptMessageCompleted).not.toHaveBeenCalled();
const callback = vi.fn();
await createReflagInstance(callback);
expect(callback).not.toHaveBeenCalled();
await vi.waitFor(() =>
expect(checkPromptMessageCompleted).toHaveBeenCalledOnce(),
);
expect(checkPromptMessageCompleted).toHaveBeenCalledWith("foo", "123");
});
test("propagates prompt to the callback", async () => {
const callback = vi.fn();
await createReflagInstance(callback);
await vi.waitUntil(() => callback.mock.calls.length > 0);
await vi.waitUntil(() => events.length > 1);
expect(events).toEqual(["received", "shown"]);
expect(callback).toBeCalledTimes(1);
expect(callback).toBeCalledWith(
{
question: "How are you?",
showAfter: new Date(message.showAfter),
showBefore: new Date(message.showBefore),
promptId: "123",
featureId: "456",
},
expect.anything(),
);
expect(markPromptMessageCompleted).not.toHaveBeenCalled();
});
test("propagates timed prompt to the callback", async () => {
const callback = vi.fn();
vi.useFakeTimers();
vi.setSystemTime(message.showAfter - 500);
await createReflagInstance(callback);
expect(callback).not.toBeCalled();
vi.runAllTimers();
await vi.waitUntil(() => callback.mock.calls.length > 0);
await vi.waitUntil(() => events.length > 1);
expect(events).toEqual(["received", "shown"]);
expect(callback).toBeCalledTimes(1);
expect(markPromptMessageCompleted).not.toHaveBeenCalled();
vi.clearAllTimers();
vi.useRealTimers();
});
test("propagates prompt to the callback and reacts to dismissal", async () => {
const callback: FeedbackPromptHandler = async (_, handlers) => {
await handlers.reply(null);
};
await createReflagInstance(callback);
await vi.waitUntil(() => events.length > 2);
expect(events).toEqual(["received", "shown", "dismissed"]);
expect(markPromptMessageCompleted).toHaveBeenCalledOnce();
expect(markPromptMessageCompleted).toHaveBeenCalledWith(
"foo",
"123",
new Date(message.showBefore),
);
});
test("propagates prompt to the callback and reacts to feedback", async () => {
const callback: FeedbackPromptHandler = async (_, handlers) => {
await handlers.reply({
companyId: "bar",
score: 5,
comment: "hello",
question: "Cum esti?",
});
};
await createReflagInstance(callback);
await vi.waitUntil(() => events.length > 1);
expect(events).toEqual(["received", "shown"]);
expect(markPromptMessageCompleted).toHaveBeenCalledOnce();
expect(markPromptMessageCompleted).toHaveBeenCalledWith(
"foo",
"123",
new Date(message.showBefore),
);
});
});
describe(`sends "check" events `, () => {
test("getFlags() does not send `check` events", async () => {
vi.spyOn(FlagsClient.prototype, "sendCheckEvent");
const client = new ReflagClient({
publishableKey: KEY,
user: { id: "123" },
});
await client.initialize();
expect(
vi.mocked(FlagsClient.prototype.sendCheckEvent),
).toHaveBeenCalledTimes(0);
const flagA = client.getFlags()?.flagA;
expect(flagA?.isEnabled).toBe(true);
expect(
vi.mocked(FlagsClient.prototype.sendCheckEvent),
).toHaveBeenCalledTimes(0);
});
describe("getFlag", async () => {
afterEach(() => {
vi.clearAllMocks();
});
it(`returns get the expected flag details`, async () => {
const client = new ReflagClient({
publishableKey: KEY,
user: { id: "uid" },
company: { id: "cid" },
});
await client.initialize();
expect(client.getFlag("flagA")).toStrictEqual({
isEnabled: true,
config: { key: undefined, payload: undefined },
track: expect.any(Function),
requestFeedback: expect.any(Function),
isEnabledOverride: null,
setIsEnabledOverride: expect.any(Function),
});
expect(client.getFlag("flagB")).toStrictEqual({
isEnabled: true,
config: {
key: "gpt3",
payload: {
model: "gpt-something",
temperature: 0.5,
},
},
track: expect.any(Function),
requestFeedback: expect.any(Function),
isEnabledOverride: null,
setIsEnabledOverride: expect.any(Function),
});
expect(client.getFlag("flagC")).toStrictEqual({
isEnabled: false,
config: { key: undefined, payload: undefined },
track: expect.any(Function),
requestFeedback: expect.any(Function),
isEnabledOverride: null,
setIsEnabledOverride: expect.any(Function),
});
});
it(`does not send check events when offline`, async () => {
const postSpy = vi.spyOn(HttpClient.prototype, "post");
const client = new ReflagClient({
publishableKey: KEY,
user: { id: "uid" },
company: { id: "cid" },
offline: true,
});
await client.initialize();
const flagA = client.getFlag("flagA");
expect(flagA.isEnabled).toBe(false);
expect(postSpy).not.toHaveBeenCalled();
});
it(`sends check event when accessing "isEnabled"`, async () => {
const sendCheckEventSpy = vi.spyOn(
FlagsClient.prototype,
"sendCheckEvent",
);
const postSpy = vi.spyOn(HttpClient.prototype, "post");
const client = new ReflagClient({
publishableKey: KEY,
user: { id: "uid" },
company: { id: "cid" },
});
await client.initialize();
const flagA = client.getFlag("flagA");
expect(sendCheckEventSpy).toHaveBeenCalledTimes(0);
expect(flagA.isEnabled).toBe(true);
expect(sendCheckEventSpy).toHaveBeenCalledTimes(1);
expect(sendCheckEventSpy).toHaveBeenCalledWith(
{
action: "check-is-enabled",
key: "flagA",
value: true,
version: 1,
missingContextFields: ["field1", "field2"],
ruleEvaluationResults: [false, true],
},
expect.any(Function),
);
expect(postSpy).toHaveBeenCalledWith({
body: {
action: "check-is-enabled",
evalContext: {
company: {
id: "cid",
},
other: {},
user: {
id: "uid",
},
},
evalResult: true,
evalRuleResults: [false, true],
evalMissingFields: ["field1", "field2"],
key: "flagA",
targetingVersion: 1,
},
path: "features/events",
});
});
it(`sends check event when accessing "config"`, async () => {
const postSpy = vi.spyOn(HttpClient.prototype, "post");
const client = new ReflagClient({
publishableKey: KEY,
user: { id: "uid" },
});
await client.initialize();
const flagB = client.getFlag("flagB");
expect(flagB.config).toMatchObject({
key: "gpt3",
});
expect(postSpy).toHaveBeenCalledWith({
body: {
action: "check-config",
evalContext: {
company: undefined,
other: {},
user: {
id: "uid",
},
},
evalResult: {
key: "gpt3",
payload: { model: "gpt-something", temperature: 0.5 },
},
evalRuleResults: [true, false, false],
evalMissingFields: ["field3"],
key: "flagB",
targetingVersion: 12,
},
path: "features/events",
});
});
it("sends check event for not-enabled flags", async () => {
// disabled flags don't appear in the API response
vi.spyOn(FlagsClient.prototype, "sendCheckEvent");
const client = new ReflagClient({ publishableKey: KEY });
await client.initialize();
const nonExistentFlag = client.getFlag("non-existent");
expect(
vi.mocked(FlagsClient.prototype.sendCheckEvent),
).toHaveBeenCalledTimes(0);
expect(nonExistentFlag.isEnabled).toBe(false);
expect(
vi.mocked(FlagsClient.prototype.sendCheckEvent),
).toHaveBeenCalledTimes(1);
expect(
vi.mocked(FlagsClient.prototype.sendCheckEvent),
).toHaveBeenCalledWith(
{
action: "check-is-enabled",
value: false,
key: "non-existent",
version: undefined,
},
expect.any(Function),
);
});
it("calls client.track with the flagKey", async () => {
const client = new ReflagClient({ publishableKey: KEY });
await client.initialize();
const flag = client.getFlag("flag-1");
expect(flag).toStrictEqual({
isEnabled: false,
track: expect.any(Function),
requestFeedback: expect.any(Function),
config: { key: undefined, payload: undefined },
isEnabledOverride: null,
setIsEnabledOverride: expect.any(Function),
});
vi.spyOn(client, "track");
await flag.track();
expect(client.track).toHaveBeenCalledWith("flag-1");
});
it("calls client.requestFeedback with the flagKey", async () => {
const client = new ReflagClient({ publishableKey: KEY });
await client.initialize();
const flag = client.getFlag("flag-1");
expect(flag).toStrictEqual({
isEnabled: false,
track: expect.any(Function),
requestFeedback: expect.any(Function),
config: { key: undefined, payload: undefined },
isEnabledOverride: null,
setIsEnabledOverride: expect.any(Function),
});
vi.spyOn(client, "requestFeedback");
flag.requestFeedback({
title: "Feedback",
});
expect(client.requestFeedback).toHaveBeenCalledWith({
flagKey: "flag-1",
title: "Feedback",
});
});
});
});
```
--------------------------------------------------------------------------------
/packages/flag-evaluation/src/index.ts:
--------------------------------------------------------------------------------
```typescript
import { sha256 } from "js-sha256";
/**
* Represents a filter class with a specific type property.
*
* This type is intended to define the structure for objects
* that classify or categorize based on a particular filter type.
*
* Properties:
* - type: Specifies the classification type as a string.
*/
export type FilterClass = {
type: string;
};
/**
* Represents a group of filters that can be combined with a logical operator.
*
* @template T The type of filter class that defines the criteria within the filter group.
* @property type The fixed type indicator for this filter structure, always "group".
* @property operator The logical operator used to combine the filters in the group. It can be either "and" (all conditions must pass) or "or" (at least one condition must pass).
* @property filters An array of filter trees containing individual filters or nested groups of filters.
*/
export type FilterGroup<T extends FilterClass> = {
type: "group";
operator: "and" | "or";
filters: FilterTree<T>[];
};
/**
* Represents a filter negation structure for use within filtering systems.
*
* A `FilterNegation` is used to encapsulate a negation operation,
* which negates the conditions defined in the provided `filter`.
*
* @template T - A generic type that extends FilterClass, indicating the type of the filter.
* @property type - Specifies the type of this filter operation as "negation".
* @property filter - A `FilterTree` structure of type `T` that defines the filter conditions to be negated.
*/
export type FilterNegation<T extends FilterClass> = {
type: "negation";
filter: FilterTree<T>;
};
/**
* Represents a tree structure for filters that can be composed of filter groups,
* filter negations, or individual filter instances of a specified type.
*
* @template T - A type that extends the `FilterClass`.
*/
export type FilterTree<T extends FilterClass> =
| FilterGroup<T>
| FilterNegation<T>
| T;
/**
* Represents a set of predefined operators that can be used to filter a specific context.
* These operators can express various conditions, including equality checks, comparison,
* set membership, and boolean evaluations.
*
* Possible values:
* - "IS": Specifies exact match.
* - "IS_NOT": Specifies a negation of exact match.
* - "ANY_OF": Checks if a value is present in a set of specified values.
* - "NOT_ANY_OF": Checks if a value is not present in a set of specified values.
* - "CONTAINS": Verifies if a value contains a specific substring or element.
* - "NOT_CONTAINS": Verifies if a value does not contain a specific substring or element.
* - "GT": Greater than comparison.
* - "LT": Less than comparison.
* - "AFTER": Compares if a value is after a specified point (e.g., time, rank).
* - "BEFORE": Compares if a value is before a specified point (e.g., time, rank).
* - "SET": Checks if a value is set or exists.
* - "NOT_SET": Checks if a value is not set or does not exist.
* - "IS_TRUE": Checks if a boolean value is true.
* - "IS_FALSE": Checks if a boolean value is false.
*/
type ContextFilterOperator =
| "IS"
| "IS_NOT"
| "ANY_OF"
| "NOT_ANY_OF"
| "CONTAINS"
| "NOT_CONTAINS"
| "GT"
| "LT"
| "AFTER"
| "BEFORE"
| "DATE_AFTER"
| "DATE_BEFORE"
| "SET"
| "NOT_SET"
| "IS_TRUE"
| "IS_FALSE";
/**
* Represents a filter configuration used to filter data based on specific context.
*
* This interface defines the structure of a context filter, containing a field,
* an operator, and optional values to control the filtering behavior.
*
* The `type` property must always have the value "context" to classify filters
* of this type.
*
* The `field` property specifies the name of the context field to filter.
*
* The `operator` property defines the filtering operation to perform on the
* specified field (e.g., equals, contains, etc.).
*
* The optional `values` property is an array of strings that lists the values
* to be used in conjunction with the operator for filtering.
*
* This interface is typically utilized in contexts where data needs to be
* dynamically filtered based on specific criteria derived from contextual
* attributes.
*/
export interface ContextFilter {
type: "context";
field: string;
operator: ContextFilterOperator;
values?: string[];
valueSet?: Set<string>;
}
/**
* Represents a filter configuration to enable percentage-based rollout of a flag or functionality.
*
* This type defines the necessary parameters to control access to a flag
* by evaluating a specific attribute and applying it against a defined percentage threshold.
*
* Properties:
* - `type` - Indicates the type of the filter. For this filter type, it will always be "rolloutPercentage".
* - `key` - A unique key or identifier that distinguishes this rollout filter.
* - `partialRolloutAttribute` - Specifies the attribute used to evaluate eligibility for the rollout.
* - `partialRolloutThreshold` - A numeric value representing the upper-bound threshold (0-100) for the percentage-based rollout.
*/
export type PercentageRolloutFilter = {
type: "rolloutPercentage";
key: string;
partialRolloutAttribute: string;
partialRolloutThreshold: number;
};
/**
* Represents a constant filter configuration.
*
* The ConstantFilter type is used to define a filter configuration with a fixed,
* immutable value. It always evaluates to the specified boolean `value`.
*
* @property {string} type - Indicates the type of filter, which is always "constant".
* @property {boolean} value - The fixed boolean value for the filter.
*/
export type ConstantFilter = {
type: "constant";
value: boolean;
};
/**
* A composite type for representing a rule-based filter system.
*
* This type is constructed using a `FilterTree` structure that consists of
* nested filters of the following types:
* - `ContextFilter`: A filter that evaluates based on specified context criteria.
* - `PercentageRolloutFilter`: A filter that performs a percentage-based rollout.
* - `ConstantFilter`: A filter that evaluates based on fixed conditions or constants.
*
* `RuleFilter` is typically used in scenarios where a hierarchical filtering mechanism
* is needed to determine outcomes based on multiple layered conditions.
*/
export type RuleFilter = FilterTree<
ContextFilter | PercentageRolloutFilter | ConstantFilter
>;
/**
* Represents a value that can be used in a rule configuration.
*
* RuleValue can take on different types, allowing flexibility based on the
* specific rule's requirements. This can include:
* - A boolean value: to represent true/false conditions.
* - A string: typically used for textual or keyword-based rules.
* - A number: for numerical rules or thresholds.
* - An object: for more complex rule definitions or configurations.
*
* This type is useful for accommodating various rule structures in applications
* that work with dynamic or user-defined regulations.
*/
type RuleValue = boolean | string | number | object;
/**
* Represents a rule that defines a filtering criterion and an associated value.
*
* @template T - Specifies the type of the associated value that extends RuleValue.
* @property {RuleFilter} filter - The filtering criterion used by the rule.
* @property {T} value - The value associated with the rule.
*/
export interface Rule<T extends RuleValue> {
filter: RuleFilter;
value: T;
}
/**
* Flattens a nested JSON object into a single-level object, with keys indicating the nesting levels.
* Keys in the resulting object are represented in a dot notation to reflect the nesting structure of the original data.
*
* @param {object} data - The nested JSON object to be flattened.
* @return {Record<string, string>} A flattened JSON object with "stringified" keys and values.
*/
export function flattenJSON(data: object): Record<string, string> {
const result: Record<string, string> = {};
if (Object.keys(data).length === 0) {
return result;
}
function recurse(value: any, prop: string) {
if (value === undefined) {
return;
}
if (value === null) {
result[prop] = "";
} else if (typeof value !== "object") {
result[prop] = String(value);
} else if (Array.isArray(value)) {
if (value.length === 0) {
result[prop] = "";
}
for (let i = 0; i < value.length; i++) {
recurse(value[i], prop ? prop + "." + i : "" + i);
}
} else {
let isEmpty = true;
for (const p in value) {
isEmpty = false;
recurse(value[p], prop ? prop + "." + p : p);
}
if (isEmpty) {
result[prop] = "";
}
}
}
recurse(data, "");
return result;
}
/**
* Converts a flattened JSON object with dot-separated keys into a nested JSON object.
*
* @param {Record<string, any>} data - The flattened JSON object where keys are dot-separated representing nested levels.
* @return {Record<string, any>} The unflattened JSON object with nested structure restored.
*/
export function unflattenJSON(data: Record<string, any>): Record<string, any> {
const result: Record<string, any> = {};
for (const i in data) {
const keys = i.split(".");
keys.reduce((acc, key, index) => {
if (index === keys.length - 1) {
if (typeof acc === "object") {
acc[key] = data[i];
}
} else if (!acc[key]) {
acc[key] = {};
}
return acc[key];
}, result);
}
return result;
}
/**
* Generates a hashed integer based on the input string. The method extracts 20 bits from the hash,
* scales it to a range between 0 and 100000, and returns the resultant integer.
*
* @param {string} hashInput - The input string used to generate the hash.
* @return {number} A number between 0 and 100000 derived from the hash of the input string.
*/
export function hashInt(hashInput: string): number {
// 1. hash the key and the partial rollout attribute
// 2. take 20 bits from the hash and divide by 2^20 - 1 to get a number between 0 and 1
// 3. multiply by 100000 to get a number between 0 and 100000 and compare it to the threshold
//
// we only need 20 bits to get to 100000 because 2^20 is 1048576
const value =
new DataView(sha256.create().update(hashInput).arrayBuffer()).getUint32(
0,
true,
) & 0xfffff;
return Math.floor((value / 0xfffff) * 100000);
}
/**
* Evaluates a field value against a specified operator and comparison values.
*
* @param {string} fieldValue - The value to be evaluated.
* @param {ContextFilterOperator} operator - The operator used for the evaluation (e.g., "CONTAINS", "GT").
* @param {string[]} values - An array of comparison values for evaluation.
* @return {boolean} The result of the evaluation based on the operator and comparison values.
*/
export function evaluate(
fieldValue: string,
operator: ContextFilterOperator,
values: string[],
valueSet?: Set<string>,
): boolean {
const value = values[0];
switch (operator) {
case "CONTAINS":
return fieldValue.toLowerCase().includes(value.toLowerCase());
case "NOT_CONTAINS":
return !fieldValue.toLowerCase().includes(value.toLowerCase());
case "GT":
if (isNaN(Number(fieldValue)) || isNaN(Number(value))) {
// TODO: return error instead? used logger previously
console.error(
`GT operator requires numeric values: ${fieldValue}, ${value}`,
);
return false;
}
return Number(fieldValue) > Number(value);
case "LT":
if (isNaN(Number(fieldValue)) || isNaN(Number(value))) {
console.error(
`LT operator requires numeric values: ${fieldValue}, ${value}`,
);
return false;
}
return Number(fieldValue) < Number(value);
case "AFTER":
case "BEFORE": {
// more/less than `value` days ago
const daysAgo = new Date();
daysAgo.setDate(daysAgo.getDate() - Number(value));
const fieldValueDate = new Date(fieldValue).getTime();
return operator === "AFTER"
? fieldValueDate > daysAgo.getTime()
: fieldValueDate < daysAgo.getTime();
}
case "DATE_AFTER":
case "DATE_BEFORE": {
const fieldValueDate = new Date(fieldValue).getTime();
const valueDate = new Date(value).getTime();
if (isNaN(fieldValueDate) || isNaN(valueDate)) {
console.error(
`${operator} operator requires valid date values: ${fieldValue}, ${value}`,
);
return false;
}
return operator === "DATE_AFTER"
? fieldValueDate >= valueDate
: fieldValueDate <= valueDate;
}
case "SET":
return fieldValue !== "";
case "NOT_SET":
return fieldValue === "";
case "IS":
return fieldValue === value;
case "IS_NOT":
return fieldValue !== value;
case "ANY_OF":
return valueSet ? valueSet.has(fieldValue) : values.includes(fieldValue);
case "NOT_ANY_OF":
return valueSet
? !valueSet.has(fieldValue)
: !values.includes(fieldValue);
case "IS_TRUE":
return fieldValue == "true";
case "IS_FALSE":
return fieldValue == "false";
default:
console.error(`unknown operator: ${operator}`);
return false;
}
}
function evaluateRecursively(
filter: RuleFilter,
context: Record<string, string>,
missingContextFieldsSet: Set<string>,
): boolean {
switch (filter.type) {
case "constant":
return filter.value;
case "context":
if (
!(filter.field in context) &&
filter.operator !== "SET" &&
filter.operator !== "NOT_SET"
) {
missingContextFieldsSet.add(filter.field);
return false;
}
return evaluate(
context[filter.field] ?? "",
filter.operator,
filter.values || [],
filter.valueSet,
);
case "rolloutPercentage": {
if (!(filter.partialRolloutAttribute in context)) {
missingContextFieldsSet.add(filter.partialRolloutAttribute);
return false;
}
const hashVal = hashInt(
`${filter.key}.${context[filter.partialRolloutAttribute]}`,
);
return hashVal < filter.partialRolloutThreshold;
}
case "group":
return filter.filters.reduce((acc, current) => {
if (filter.operator === "and") {
return (
acc &&
evaluateRecursively(current, context, missingContextFieldsSet)
);
}
return (
acc || evaluateRecursively(current, context, missingContextFieldsSet)
);
}, filter.operator === "and");
case "negation":
return !evaluateRecursively(
filter.filter,
context,
missingContextFieldsSet,
);
default:
return false;
}
}
/**
* Represents the parameters required for evaluating rules against a specific flag in a given context.
*
* @template T - The type of the rule value used in evaluation.
*
* @property {string} flagKey - The key that identifies the specific flag to be evaluated.
* @property {Rule<T>[]} rules - An array of rules used for evaluation.
* @property {Record<string, unknown>} context - The contextual data used during the evaluation process.
*/
export interface EvaluationParams<T extends RuleValue> {
flagKey: string;
rules: Rule<T>[];
context: Record<string, unknown>;
}
/**
* Represents the result of an evaluation process for a specific flag and its associated rules.
*
* @template T - The type of the rule value being evaluated.
*
* @property {string} flagKey - The unique key identifying the flag being evaluated.
* @property {T | undefined} value - The resolved value of the flag, if the evaluation is successful.
* @property {Record<string, any>} context - The contextual information used during the evaluation process.
* @property {boolean[]} ruleEvaluationResults - Array indicating the success or failure of each rule evaluated.
* @property {string} [reason] - Optional field providing additional explanation regarding the evaluation result.
* @property {string[]} [missingContextFields] - Optional array of context fields that were required but not provided during the evaluation.
*/
export interface EvaluationResult<T extends RuleValue> {
flagKey: string;
value: T | undefined;
context: Record<string, any>;
ruleEvaluationResults: boolean[];
reason?: string;
missingContextFields?: string[];
}
export function evaluateFlagRules<T extends RuleValue>({
context,
flagKey,
rules,
}: EvaluationParams<T>): EvaluationResult<T> {
const flatContext = flattenJSON(context);
const missingContextFieldsSet = new Set<string>();
const ruleEvaluationResults = rules.map((rule) =>
evaluateRecursively(rule.filter, flatContext, missingContextFieldsSet),
);
const missingContextFields = Array.from(missingContextFieldsSet);
const firstMatchedRuleIndex = ruleEvaluationResults.findIndex(Boolean);
const firstMatchedRule =
firstMatchedRuleIndex > -1 ? rules[firstMatchedRuleIndex] : undefined;
return {
value: firstMatchedRule?.value,
flagKey,
context: flatContext,
ruleEvaluationResults,
reason:
firstMatchedRuleIndex > -1
? `rule #${firstMatchedRuleIndex} matched`
: "no matched rules",
missingContextFields,
};
}
export function newEvaluator<T extends RuleValue>(rules: Rule<T>[]) {
function translateRule(rule: RuleFilter): RuleFilter {
if (rule.type === "group") {
return {
...rule,
filters: rule.filters.map(translateRule),
};
}
if (
rule.type === "context" &&
(rule.operator === "ANY_OF" || rule.operator === "NOT_ANY_OF")
) {
return {
...rule,
valueSet: new Set(rule.values ?? []),
};
}
return { ...rule };
}
const translatedRules = rules.map((rule) => {
const { filter } = rule;
const translatedFilter = translateRule(filter);
return {
...rule,
filter: translatedFilter,
};
});
return function evaluateOptimized(
context: Record<string, unknown>,
flagKey: string,
) {
return evaluateFlagRules({
context,
flagKey,
rules: translatedRules,
});
};
}
```
--------------------------------------------------------------------------------
/packages/browser-sdk/src/client.ts:
--------------------------------------------------------------------------------
```typescript
import { deepEqual } from "fast-equals";
import {
AutoFeedback,
Feedback,
feedback,
FeedbackOptions,
RequestFeedbackData,
RequestFeedbackOptions,
} from "./feedback/feedback";
import * as feedbackLib from "./feedback/ui";
import {
CheckEvent,
FallbackFlagOverride,
FlagsClient,
RawFlags,
} from "./flag/flags";
import { ToolbarPosition } from "./ui/types";
import {
API_BASE_URL,
APP_BASE_URL,
IS_SERVER,
SSE_REALTIME_BASE_URL,
} from "./config";
import { ReflagContext, ReflagDeprecatedContext } from "./context";
import { HookArgs, HooksManager, State } from "./hooksManager";
import { HttpClient } from "./httpClient";
import { Logger, loggerWithPrefix, quietConsoleLogger } from "./logger";
import { showToolbarToggle } from "./toolbar";
const isMobile = typeof window !== "undefined" && window.innerWidth < 768;
const isNode = typeof document === "undefined"; // deno supports "window" but not "document" according to https://remix.run/docs/en/main/guides/gotchas
/**
* (Internal) User context.
*
* @internal
*/
export type User = {
/**
* Identifier of the user.
*/
userId: string;
/**
* User attributes.
*/
attributes?: {
/**
* Name of the user.
*/
name?: string;
/**
* Email of the user.
*/
email?: string;
/**
* Avatar URL of the user.
*/
avatar?: string;
/**
* Custom attributes of the user.
*/
[key: string]: any;
};
/**
* Custom context of the user.
*/
context?: PayloadContext;
};
/**
* (Internal) Company context.
*
* @internal
*/
export type Company = {
/**
* User identifier.
*/
userId: string;
/**
* Company identifier.
*/
companyId: string;
/**
* Company attributes.
*/
attributes?: {
/**
* Name of the company.
*/
name?: string;
/**
* Custom attributes of the company.
*/
[key: string]: any;
};
context?: PayloadContext;
};
/**
* Tracked event.
*/
export type TrackedEvent = {
/**
* Event name.
*/
event: string;
/**
* User identifier.
*/
userId: string;
/**
* Company identifier.
*/
companyId?: string;
/**
* Event attributes.
*/
attributes?: Record<string, any>;
/**
* Custom context of the event.
*/
context?: PayloadContext;
};
/**
* (Internal) Custom context of the event.
*
* @internal
*/
export type PayloadContext = {
/**
* Whether the company and user associated with the event are active.
*/
active?: boolean;
};
/**
* ReflagClient configuration.
*/
export interface Config {
/**
* Base URL of Reflag servers.
*/
apiBaseUrl: string;
/**
* Base URL of the Reflag web app.
*/
appBaseUrl: string;
/**
* Base URL of Reflag servers for SSE connections used by AutoFeedback.
*/
sseBaseUrl: string;
/**
* Whether to enable tracking.
*/
enableTracking: boolean;
/**
* Whether to enable offline mode.
*/
offline: boolean;
/**
* Whether the client is bootstrapped.
*/
bootstrapped: boolean;
}
/**
* Toolbar options.
*/
export type ToolbarOptions =
| boolean
| {
show?: boolean;
position?: ToolbarPosition;
};
/**
* Flag definitions.
*/
export type FlagDefinitions = Readonly<Array<string>>;
/**
* ReflagClient initialization options.
*/
export type InitOptions = ReflagDeprecatedContext & {
/**
* Publishable key for authentication
*/
publishableKey: string;
/**
* You can provide a logger to see the logs of the network calls.
* This is undefined by default.
* For debugging purposes you can just set the browser console to this property:
* ```javascript
* options.logger = window.console;
* ```
*/
logger?: Logger;
/**
* Base URL of Reflag servers. You can override this to use your mocked server.
*/
apiBaseUrl?: string;
/**
* Base URL of the Reflag web app. Links open ín this app by default.
*/
appBaseUrl?: string;
/**
* Whether to enable offline mode. Defaults to `false`.
*/
offline?: boolean;
/**
* Flag keys for which `isEnabled` should fallback to true
* if SDK fails to fetch flags from Reflag servers. If a record
* is supplied instead of array, the values of each key represent the
* configuration values and `isEnabled` is assume `true`.
*/
fallbackFlags?: string[] | Record<string, FallbackFlagOverride>;
/**
* Timeout in milliseconds when fetching flags
*/
timeoutMs?: number;
/**
* If set to true stale flags will be returned while refetching flags
*/
staleWhileRevalidate?: boolean;
/**
* If set, flags will be cached between page loads for this duration
*/
expireTimeMs?: number;
/**
* Stale flags will be returned if staleWhileRevalidate is true if no new flags can be fetched
*/
staleTimeMs?: number;
/**
* When proxying requests, you may want to include credentials like cookies
* so you can authorize the request in the proxy.
* This option controls the `credentials` option of the fetch API.
*/
credentials?: "include" | "same-origin" | "omit";
/**
* Base URL of Reflag servers for SSE connections used by AutoFeedback.
*/
sseBaseUrl?: string;
/**
* AutoFeedback specific configuration
*/
feedback?: FeedbackOptions;
/**
* Version of the SDK
*/
sdkVersion?: string;
/**
* Whether to enable tracking. Defaults to `true`.
*/
enableTracking?: boolean;
/**
* Toolbar configuration
*/
toolbar?: ToolbarOptions;
/**
* Pre-fetched flags to be used instead of fetching them from the server.
*/
bootstrappedFlags?: RawFlags;
};
const defaultConfig: Config = {
apiBaseUrl: API_BASE_URL,
appBaseUrl: APP_BASE_URL,
sseBaseUrl: SSE_REALTIME_BASE_URL,
enableTracking: true,
offline: false,
bootstrapped: false,
};
/**
* A remotely managed configuration value for a flag.
*/
export type FlagRemoteConfig =
| {
/**
* The key of the matched configuration value.
*/
key: string;
/**
* The optional user-supplied payload data.
*/
payload: any;
}
| { key: undefined; payload: undefined };
/**
* Represents a flag.
*/
export interface Flag {
/**
* Result of flag flag evaluation.
* Note: Does not take local overrides into account.
*/
isEnabled: boolean;
/*
* Optional user-defined configuration.
*/
config: FlagRemoteConfig;
/**
* Function to send analytics events for this flag.
*/
track: () => Promise<Response | undefined>;
/**
* Function to request feedback for this flag.
*/
requestFeedback: (
options: Omit<RequestFeedbackData, "flagKey" | "featureId">,
) => void;
/**
* The current override status of isEnabled for the flag.
*/
isEnabledOverride: boolean | null;
/**
* Set the override status for isEnabled for the flag.
* Set to `null` to remove the override.
*/
setIsEnabledOverride(isEnabled: boolean | null): void;
}
function shouldShowToolbar(opts: InitOptions) {
const toolbarOpts = opts.toolbar;
if (typeof window === "undefined") return false;
if (typeof toolbarOpts === "boolean") return toolbarOpts;
if (typeof toolbarOpts?.show === "boolean") return toolbarOpts.show;
return window.location.hostname === "localhost";
}
/**
* ReflagClient lets you interact with the Reflag API.
*/
export class ReflagClient {
private state: State = "idle";
private readonly publishableKey: string;
private context: ReflagContext;
private config: Config;
private requestFeedbackOptions: Partial<RequestFeedbackOptions>;
private readonly httpClient: HttpClient;
private readonly autoFeedback: AutoFeedback | undefined;
private autoFeedbackInit: Promise<void> | undefined;
private readonly flagsClient: FlagsClient;
public readonly logger: Logger;
private readonly hooks: HooksManager;
/**
* Create a new ReflagClient instance.
*/
constructor(opts: InitOptions) {
this.publishableKey = opts.publishableKey;
this.logger =
opts?.logger ?? loggerWithPrefix(quietConsoleLogger, "[Reflag]");
// Create the context object making sure to clone the user and company objects
this.context = {
user: opts?.user?.id ? { ...opts.user } : undefined,
company: opts?.company?.id ? { ...opts.company } : undefined,
other: { ...opts?.otherContext, ...opts?.other },
};
this.config = {
apiBaseUrl: opts?.apiBaseUrl ?? defaultConfig.apiBaseUrl,
appBaseUrl: opts?.appBaseUrl ?? defaultConfig.appBaseUrl,
sseBaseUrl: opts?.sseBaseUrl ?? defaultConfig.sseBaseUrl,
enableTracking: opts?.enableTracking ?? defaultConfig.enableTracking,
offline: opts?.offline ?? defaultConfig.offline,
bootstrapped:
opts && "bootstrappedFlags" in opts && !!opts.bootstrappedFlags,
};
this.requestFeedbackOptions = {
position: opts?.feedback?.ui?.position,
translations: opts?.feedback?.ui?.translations,
};
this.httpClient = new HttpClient(this.publishableKey, {
baseUrl: this.config.apiBaseUrl,
sdkVersion: opts?.sdkVersion,
credentials: opts?.credentials,
});
this.flagsClient = new FlagsClient(
this.httpClient,
this.context,
this.logger,
{
bootstrappedFlags: opts.bootstrappedFlags,
expireTimeMs: opts.expireTimeMs,
staleTimeMs: opts.staleTimeMs,
staleWhileRevalidate: opts.staleWhileRevalidate,
timeoutMs: opts.timeoutMs,
fallbackFlags: opts.fallbackFlags,
offline: this.config.offline,
},
);
if (
!this.config.offline &&
this.context?.user &&
!isNode && // do not prompt on server-side
opts?.feedback?.enableAutoFeedback !== false // default to on
) {
if (isMobile) {
this.logger.warn(
"Feedback prompting is not supported on mobile devices",
);
} else {
this.autoFeedback = new AutoFeedback(
this.config.sseBaseUrl,
this.logger,
this.httpClient,
opts?.feedback?.autoFeedbackHandler,
String(this.context.user?.id),
opts?.feedback?.ui?.position,
opts?.feedback?.ui?.translations,
);
}
}
if (shouldShowToolbar(opts)) {
this.logger.info("opening toolbar toggler");
showToolbarToggle({
reflagClient: this,
position:
typeof opts.toolbar === "object" ? opts.toolbar.position : undefined,
});
}
// Register hooks
this.hooks = new HooksManager();
this.flagsClient.onUpdated(() => {
this.hooks.trigger("flagsUpdated", this.flagsClient.getFlags());
});
}
/**
* Initialize the Reflag SDK.
*
* Must be called before calling other SDK methods.
*/
async initialize() {
if (this.state === "initializing" || this.state === "initialized") {
this.logger.warn(`"Reflag client already ${this.state}`);
return;
}
this.setState("initializing");
const start = Date.now();
if (this.autoFeedback && !IS_SERVER) {
// do not block on automated feedback surveys initialization
this.autoFeedbackInit = this.autoFeedback.initialize().catch((e) => {
this.logger.error("error initializing automated feedback surveys", e);
});
}
await this.flagsClient.initialize();
if (!this.config.bootstrapped) {
if (this.context.user && this.config.enableTracking) {
this.user().catch((e) => {
this.logger.error("error sending user", e);
});
}
if (this.context.company && this.config.enableTracking) {
this.company().catch((e) => {
this.logger.error("error sending company", e);
});
}
}
this.logger.info(
"Reflag initialized in " +
Math.round(Date.now() - start) +
"ms" +
(this.config.offline ? " (offline mode)" : ""),
);
this.setState("initialized");
}
/**
* Stop the SDK.
* This will stop any automated feedback surveys.
*
**/
async stop() {
if (this.autoFeedback) {
// ensure fully initialized before stopping
await this.autoFeedbackInit;
this.autoFeedback.stop();
}
this.flagsClient.stop();
this.setState("stopped");
}
getState() {
return this.state;
}
/**
* Add an event listener
*
* @param type Type of events to listen for
* @param handler The function to call when the event is triggered.
* @returns A function to remove the hook.
*/
on<THookType extends keyof HookArgs>(
type: THookType,
handler: (args0: HookArgs[THookType]) => void,
) {
return this.hooks.addHook(type, handler);
}
/**
* Remove an event listener
*
* @param type Type of event to remove.
* @param handler The same function that was passed to `on`.
*
* @returns A function to remove the hook.
*/
off<THookType extends keyof HookArgs>(
type: THookType,
handler: (args0: HookArgs[THookType]) => void,
) {
this.hooks.removeHook(type, handler);
}
/**
* Get the current context.
*/
getContext() {
return this.context;
}
/**
* Get the current configuration.
*/
getConfig() {
return this.config;
}
/**
* Update the user context.
* Performs a shallow merge with the existing user context.
* It will not update the context if nothing has changed.
*
* @param user
*/
async updateUser(user: { [key: string]: string | number | undefined }) {
const userIdChanged = user.id && user.id !== this.context.user?.id;
const newUserContext = {
...this.context.user,
...user,
id: user.id ?? this.context.user?.id,
};
// Nothing has changed, skipping update
if (deepEqual(this.context.user, newUserContext)) return;
this.context.user = newUserContext;
void this.user();
// Update the feedback user if the user ID has changed
if (userIdChanged) {
void this.updateAutoFeedbackUser(String(user.id));
}
await this.flagsClient.setContext(this.context);
}
/**
* Update the company context.
* Performs a shallow merge with the existing company context.
* It will not update the context if nothing has changed.
*
* @param company The company details.
*/
async updateCompany(company: { [key: string]: string | number | undefined }) {
const newCompanyContext = {
...this.context.company,
...company,
id: company.id ?? this.context.company?.id,
};
// Nothing has changed, skipping update
if (deepEqual(this.context.company, newCompanyContext)) return;
this.context.company = newCompanyContext;
void this.company();
await this.flagsClient.setContext(this.context);
}
/**
* Update the company context.
* Performs a shallow merge with the existing company context.
* It will not update the context if nothing has changed.
*
* @param otherContext Additional context.
*/
async updateOtherContext(otherContext: {
[key: string]: string | number | undefined;
}) {
const newOtherContext = {
...this.context.other,
...otherContext,
};
// Nothing has changed, skipping update
if (deepEqual(this.context.other, newOtherContext)) return;
this.context.other = newOtherContext;
await this.flagsClient.setContext(this.context);
}
/**
* Update the context.
* Replaces the existing context with a new context.
*
* @param context The context to update.
*/
async setContext({ otherContext, ...context }: ReflagDeprecatedContext) {
const userIdChanged =
context.user?.id && context.user.id !== this.context.user?.id;
// Create a new context object making sure to clone the user and company objects
const newContext = {
user: context.user?.id ? { ...context.user } : undefined,
company: context.company?.id ? { ...context.company } : undefined,
other: { ...otherContext, ...context.other },
};
if (!context.user?.id) {
this.logger.warn("No user Id provided in context, user will be ignored");
}
if (!context.company?.id) {
this.logger.warn(
"No company Id provided in context, company will be ignored",
);
}
// Nothing has changed, skipping update
if (deepEqual(this.context, newContext)) return;
this.context = newContext;
if (context.company) {
void this.company();
}
if (context.user) {
void this.user();
// Update the automatic feedback user if the user ID has changed
if (userIdChanged) {
void this.updateAutoFeedbackUser(String(context.user.id));
}
}
await this.flagsClient.setContext(this.context);
}
/**
* Update the flags.
*
* @param flags The flags to update.
* @param triggerEvent Whether to trigger the `flagsUpdated` event.
*/
updateFlags(flags: RawFlags, triggerEvent = true) {
this.flagsClient.setFetchedFlags(flags, triggerEvent);
}
/**
* Track an event in Reflag.
*
* @param eventName The name of the event.
* @param attributes Any attributes you want to attach to the event.
*/
async track(eventName: string, attributes?: Record<string, any> | null) {
if (!this.context.user) {
this.logger.warn("'track' call ignored. No user context provided");
return;
}
if (!this.config.enableTracking) {
this.logger.warn("'track' call ignored. 'enableTracking' is false");
return;
}
if (this.config.offline) {
return;
}
const payload: TrackedEvent = {
userId: String(this.context.user.id),
event: eventName,
};
if (attributes) payload.attributes = attributes;
if (this.context.company?.id)
payload.companyId = String(this.context.company?.id);
const res = await this.httpClient.post({ path: `/event`, body: payload });
this.logger.debug(`sent event`, res);
this.hooks.trigger("track", {
eventName,
attributes,
user: this.context.user,
company: this.context.company,
});
return res;
}
/**
* Submit user feedback to Reflag. Must include either `score` or `comment`, or both.
*
* @param payload The feedback details to submit.
* @returns The server response.
*/
async feedback(payload: Feedback) {
if (this.config.offline) {
return;
}
const userId =
payload.userId ||
(this.context.user?.id ? String(this.context.user?.id) : undefined);
const companyId =
payload.companyId ||
(this.context.company?.id ? String(this.context.company?.id) : undefined);
return await feedback(this.httpClient, this.logger, {
userId,
companyId,
...payload,
});
}
/**
* Display the Reflag feedback form UI programmatically.
*
* This can be used to collect feedback from users in Reflag in cases where Automated Feedback Surveys isn't appropriate.
*
* @param options
*/
requestFeedback(options: RequestFeedbackData) {
if (!this.context.user?.id) {
this.logger.error(
"`requestFeedback` call ignored. No `user` context provided at initialization",
);
return;
}
if (!options.flagKey) {
this.logger.error(
"`requestFeedback` call ignored. No `flagKey` provided",
);
return;
}
const feedbackData = {
flagKey: options.flagKey,
companyId:
options.companyId ||
(this.context.company?.id
? String(this.context.company?.id)
: undefined),
source: "widget" as const,
} satisfies Feedback;
// Wait a tick before opening the feedback form,
// to prevent the same click from closing it.
setTimeout(() => {
feedbackLib.openFeedbackForm({
key: options.flagKey,
title: options.title,
position: options.position || this.requestFeedbackOptions.position,
translations:
options.translations || this.requestFeedbackOptions.translations,
openWithCommentVisible: options.openWithCommentVisible,
onClose: options.onClose,
onDismiss: options.onDismiss,
onScoreSubmit: async (data) => {
const res = await this.feedback({
...feedbackData,
...data,
});
if (res) {
const json = await res.json();
return { feedbackId: json.feedbackId };
}
return { feedbackId: undefined };
},
onSubmit: async (data) => {
// Default onSubmit handler
await this.feedback({
...feedbackData,
...data,
});
options.onAfterSubmit?.(data);
},
});
}, 1);
}
/**
* @deprecated Use `getFlags` instead.
*/
getFeatures() {
return this.getFlags();
}
/**
* Returns a map of enabled flags.
* Accessing a flag will *not* send a check event
* and `isEnabled` does not take any flag overrides
* into account.
*
* @returns Map of flags.
*/
getFlags(): RawFlags {
return this.flagsClient.getFlags();
}
/**
* @deprecated Use `getFlag` instead.
*/
getFeature(flagKey: string) {
return this.getFlag(flagKey);
}
/**
* Return a flag. Accessing `isEnabled` or `config` will automatically send a `check` event.
*
* @param flagKey - The key of the flag to get.
* @returns A flag.
*/
getFlag(flagKey: string): Flag {
const f = this.getFlags()[flagKey];
// eslint-disable-next-line @typescript-eslint/no-this-alias
const self = this;
const value = f?.isEnabledOverride ?? f?.isEnabled ?? false;
const config = f?.config
? {
key: f.config.key,
payload: f.config.payload,
}
: { key: undefined, payload: undefined };
return {
get isEnabled() {
self
.sendCheckEvent({
action: "check-is-enabled",
key: flagKey,
version: f?.targetingVersion,
ruleEvaluationResults: f?.ruleEvaluationResults,
missingContextFields: f?.missingContextFields,
value,
})
.catch(() => {
// ignore
});
return value;
},
get config() {
self
.sendCheckEvent({
action: "check-config",
key: flagKey,
version: f?.config?.version,
ruleEvaluationResults: f?.config?.ruleEvaluationResults,
missingContextFields: f?.config?.missingContextFields,
value: f?.config && {
key: f.config.key,
payload: f.config.payload,
},
})
.catch(() => {
// ignore
});
return config;
},
track: () => this.track(flagKey),
requestFeedback: (
options: Omit<RequestFeedbackData, "flagKey" | "featureId">,
) => {
this.requestFeedback({
flagKey,
...options,
});
},
isEnabledOverride: this.flagsClient.getFlagOverride(flagKey),
setIsEnabledOverride(isEnabled: boolean | null) {
self.flagsClient.setFlagOverride(flagKey, isEnabled);
},
};
}
private setState(state: State) {
this.state = state;
this.hooks.trigger("stateUpdated", state);
}
private sendCheckEvent(checkEvent: CheckEvent) {
return this.flagsClient.sendCheckEvent(checkEvent, () => {
this.hooks.trigger("check", checkEvent);
});
}
/**
* Send attributes to Reflag for the current user
*/
private async user() {
if (!this.context.user) {
this.logger.warn(
"`user` call ignored. No user context provided at initialization",
);
return;
}
if (this.config.offline) {
return;
}
const { id, ...attributes } = this.context.user;
const payload: User = {
userId: String(id),
attributes,
};
const res = await this.httpClient.post({ path: `/user`, body: payload });
this.logger.debug(`sent user`, res);
this.hooks.trigger("user", this.context.user);
return res;
}
/**
* Send attributes to Reflag for the current company.
*/
private async company() {
if (!this.context.user) {
this.logger.warn(
"`company` call ignored. No user context provided at initialization",
);
return;
}
if (!this.context.company) {
this.logger.warn(
"`company` call ignored. No company context provided at initialization",
);
return;
}
if (this.config.offline) {
return;
}
const { id, ...attributes } = this.context.company;
const payload: Company = {
userId: String(this.context.user.id),
companyId: String(id),
attributes,
};
const res = await this.httpClient.post({ path: `/company`, body: payload });
this.logger.debug(`sent company`, res);
this.hooks.trigger("company", this.context.company);
return res;
}
private async updateAutoFeedbackUser(userId: string) {
if (!this.autoFeedback) {
return;
}
// Ensure fully initialized before updating the user
await this.autoFeedbackInit;
await this.autoFeedback.setUser(userId);
}
}
```
--------------------------------------------------------------------------------
/packages/flag-evaluation/test/index.test.ts:
--------------------------------------------------------------------------------
```typescript
import { afterAll, beforeAll, describe, expect, it, vi } from "vitest";
import {
evaluate,
evaluateFlagRules,
EvaluationParams,
flattenJSON,
hashInt,
newEvaluator,
unflattenJSON,
} from "../src";
const flag = {
flagKey: "flag",
rules: [
{
value: true,
filter: {
type: "group",
operator: "and",
filters: [
{
type: "context",
field: "company.id",
operator: "IS",
values: ["company1"],
},
{
type: "rolloutPercentage",
key: "flag",
partialRolloutAttribute: "company.id",
partialRolloutThreshold: 100000,
},
],
},
},
],
} satisfies Omit<EvaluationParams<true>, "context">;
describe("evaluate flag targeting integration ", () => {
it("evaluates all kinds of filters", async () => {
const res = evaluateFlagRules({
flagKey: "flag",
rules: [
{
value: true,
filter: {
type: "group",
operator: "and",
filters: [
{
type: "context",
field: "company.id",
operator: "IS",
values: ["company1"],
},
{
type: "rolloutPercentage",
key: "flag",
partialRolloutAttribute: "company.id",
partialRolloutThreshold: 99999,
},
{
type: "group",
operator: "or",
filters: [
{
type: "context",
field: "company.id",
operator: "IS",
values: ["company2"],
},
{
type: "negation",
filter: {
type: "context",
field: "company.id",
operator: "IS",
values: ["company3"],
},
},
],
},
{
type: "negation",
filter: {
type: "constant",
value: false,
},
},
],
},
},
],
context: {
"company.id": "company1",
},
});
expect(res).toEqual({
value: true,
context: {
"company.id": "company1",
},
flagKey: "flag",
missingContextFields: [],
reason: "rule #0 matched",
ruleEvaluationResults: [true],
});
});
it("evaluates flag when there's no matching rule", async () => {
const res = evaluateFlagRules({
...flag,
context: {
company: {
id: "wrong value",
},
},
});
expect(res).toEqual({
value: undefined,
context: {
"company.id": "wrong value",
},
flagKey: "flag",
missingContextFields: [],
reason: "no matched rules",
ruleEvaluationResults: [false],
});
});
it("evaluates targeting when there's a matching rule", async () => {
const context = {
company: {
id: "company1",
},
};
const res = evaluateFlagRules({
...flag,
context,
});
expect(res).toEqual({
value: true,
context: {
"company.id": "company1",
},
flagKey: "flag",
missingContextFields: [],
reason: "rule #0 matched",
ruleEvaluationResults: [true],
});
});
it("evaluates flag with missing values", async () => {
const res = evaluateFlagRules({
flagKey: "flag",
rules: [
{
value: { custom: "value" },
filter: {
type: "group",
operator: "and",
filters: [
{
type: "context",
field: "some_field",
operator: "IS",
values: [""],
},
{
type: "rolloutPercentage",
key: "flag",
partialRolloutAttribute: "some_field",
partialRolloutThreshold: 99000,
},
],
},
},
],
context: {
some_field: "",
},
});
expect(res).toEqual({
context: {
some_field: "",
},
value: { custom: "value" },
flagKey: "flag",
missingContextFields: [],
reason: "rule #0 matched",
ruleEvaluationResults: [true],
});
});
it("returns list of missing context keys ", async () => {
const res = evaluateFlagRules({
...flag,
context: {},
});
expect(res).toEqual({
context: {},
value: undefined,
reason: "no matched rules",
flagKey: "flag",
missingContextFields: ["company.id"],
ruleEvaluationResults: [false],
});
});
it("fails evaluation and includes key in missing keys when rollout attribute is missing from context", async () => {
const res = evaluateFlagRules({
flagKey: "flag-1",
rules: [
{
value: 123,
filter: {
type: "rolloutPercentage" as const,
key: "flag-1",
partialRolloutAttribute: "happening.id",
partialRolloutThreshold: 50000,
},
},
],
context: {},
});
expect(res).toEqual({
flagKey: "flag-1",
context: {},
value: undefined,
reason: "no matched rules",
missingContextFields: ["happening.id"],
ruleEvaluationResults: [false],
});
});
it("evaluates optimized rule evaluations correctly", async () => {
const res = newEvaluator([
{
value: true,
filter: {
type: "group",
operator: "and",
filters: [
{
type: "context",
field: "company.id",
operator: "IS",
values: ["company1"],
},
{
type: "rolloutPercentage",
key: "flag",
partialRolloutAttribute: "company.id",
partialRolloutThreshold: 99999,
},
{
type: "group",
operator: "or",
filters: [
{
type: "context",
field: "company.id",
operator: "ANY_OF",
values: ["company2"],
},
{
type: "negation",
filter: {
type: "context",
field: "company.id",
operator: "IS",
values: ["company3"],
},
},
],
},
{
type: "negation",
filter: {
type: "constant",
value: false,
},
},
],
},
},
])(
{
"company.id": "company1",
},
"flag",
);
expect(res).toEqual({
value: true,
context: {
"company.id": "company1",
},
flagKey: "flag",
missingContextFields: [],
reason: "rule #0 matched",
ruleEvaluationResults: [true],
});
});
describe("SET and NOT_SET operators", () => {
it("should handle `SET` operator with missing field value", () => {
const res = evaluateFlagRules({
flagKey: "test_flag",
rules: [
{
value: true,
filter: {
type: "context",
field: "user.name",
operator: "SET",
values: [],
},
},
],
context: {},
});
expect(res).toEqual({
flagKey: "test_flag",
value: undefined,
context: {},
ruleEvaluationResults: [false],
reason: "no matched rules",
missingContextFields: [],
});
});
it("should handle `NOT_SET` operator with missing field value", () => {
const res = evaluateFlagRules({
flagKey: "test_flag",
rules: [
{
value: true,
filter: {
type: "context",
field: "user.name",
operator: "NOT_SET",
values: [],
},
},
],
context: {},
});
expect(res).toEqual({
flagKey: "test_flag",
value: true,
context: {},
ruleEvaluationResults: [true],
reason: "rule #0 matched",
missingContextFields: [],
});
});
it("should handle `SET` operator with empty string field value", () => {
const res = evaluateFlagRules({
flagKey: "test_flag",
rules: [
{
value: true,
filter: {
type: "context",
field: "user.name",
operator: "SET",
values: [],
},
},
],
context: {
user: {
name: "",
},
},
});
expect(res).toEqual({
flagKey: "test_flag",
value: undefined,
context: {
"user.name": "",
},
ruleEvaluationResults: [false],
reason: "no matched rules",
missingContextFields: [],
});
});
it("should handle `NOT_SET` operator with empty string field value", () => {
const res = evaluateFlagRules({
flagKey: "test_flag",
rules: [
{
value: true,
filter: {
type: "context",
field: "user.name",
operator: "NOT_SET",
values: [],
},
},
],
context: {
user: {
name: "",
},
},
});
expect(res).toEqual({
flagKey: "test_flag",
value: true,
context: {
"user.name": "",
},
ruleEvaluationResults: [true],
reason: "rule #0 matched",
missingContextFields: [],
});
});
});
it.each([
{
context: { "company.id": "company1" },
expected: true,
},
{
context: { "company.id": "company2" },
expected: true,
},
{
context: { "company.id": "company3" },
expected: false,
},
])(
"%#: evaluates optimized rule evaluations correctly",
async ({ context, expected }) => {
const evaluator = newEvaluator([
{
value: true,
filter: {
type: "group",
operator: "and",
filters: [
{
type: "context",
field: "company.id",
operator: "ANY_OF",
values: ["company1", "company2"],
},
],
},
},
]);
const res = evaluator(context, "flag-1");
expect(res.value ?? false).toEqual(expected);
},
);
describe("DATE_AFTER and DATE_BEFORE in flag rules", () => {
it("should evaluate DATE_AFTER operator in flag rules", () => {
const res = evaluateFlagRules({
flagKey: "time_based_flag",
rules: [
{
value: "enabled",
filter: {
type: "context",
field: "user.createdAt",
operator: "DATE_AFTER",
values: ["2024-01-01"],
},
},
],
context: {
user: {
createdAt: "2024-06-15",
},
},
});
expect(res).toEqual({
flagKey: "time_based_flag",
value: "enabled",
context: {
"user.createdAt": "2024-06-15",
},
ruleEvaluationResults: [true],
reason: "rule #0 matched",
missingContextFields: [],
});
});
it("should evaluate DATE_BEFORE operator in flag rules", () => {
const res = evaluateFlagRules({
flagKey: "legacy_flag",
rules: [
{
value: "enabled",
filter: {
type: "context",
field: "user.lastLogin",
operator: "DATE_BEFORE",
values: ["2024-12-31"],
},
},
],
context: {
user: {
lastLogin: "2024-01-15",
},
},
});
expect(res).toEqual({
flagKey: "legacy_flag",
value: "enabled",
context: {
"user.lastLogin": "2024-01-15",
},
ruleEvaluationResults: [true],
reason: "rule #0 matched",
missingContextFields: [],
});
});
it("should handle complex rules with DATE_AFTER and DATE_BEFORE in groups", () => {
const res = evaluateFlagRules({
flagKey: "time_window_flag",
rules: [
{
value: "active",
filter: {
type: "group",
operator: "and",
filters: [
{
type: "context",
field: "event.startDate",
operator: "DATE_AFTER",
values: ["2024-01-01"],
},
{
type: "context",
field: "event.endDate",
operator: "DATE_BEFORE",
values: ["2024-12-31"],
},
],
},
},
],
context: {
event: {
startDate: "2024-06-01",
endDate: "2024-11-30",
},
},
});
expect(res).toEqual({
flagKey: "time_window_flag",
value: "active",
context: {
"event.startDate": "2024-06-01",
"event.endDate": "2024-11-30",
},
ruleEvaluationResults: [true],
reason: "rule #0 matched",
missingContextFields: [],
});
});
it("should fail when DATE_AFTER condition is not met", () => {
const res = evaluateFlagRules({
flagKey: "future_flag",
rules: [
{
value: "enabled",
filter: {
type: "context",
field: "user.signupDate",
operator: "DATE_AFTER",
values: ["2024-12-01"],
},
},
],
context: {
user: {
signupDate: "2024-01-15", // Too early
},
},
});
expect(res).toEqual({
flagKey: "future_flag",
value: undefined,
context: {
"user.signupDate": "2024-01-15",
},
ruleEvaluationResults: [false],
reason: "no matched rules",
missingContextFields: [],
});
});
it("should fail when DATE_BEFORE condition is not met", () => {
const res = evaluateFlagRules({
flagKey: "past_flag",
rules: [
{
value: "enabled",
filter: {
type: "context",
field: "user.lastActivity",
operator: "DATE_BEFORE",
values: ["2024-01-01"],
},
},
],
context: {
user: {
lastActivity: "2024-06-15", // Too late
},
},
});
expect(res).toEqual({
flagKey: "past_flag",
value: undefined,
context: {
"user.lastActivity": "2024-06-15",
},
ruleEvaluationResults: [false],
reason: "no matched rules",
missingContextFields: [],
});
});
it("should work with optimized evaluator", () => {
const evaluator = newEvaluator([
{
value: "time_sensitive",
filter: {
type: "group",
operator: "and",
filters: [
{
type: "context",
field: "user.subscriptionDate",
operator: "DATE_AFTER",
values: ["2024-01-01"],
},
{
type: "context",
field: "user.trialEndDate",
operator: "DATE_BEFORE",
values: ["2024-12-31"],
},
],
},
},
]);
const res = evaluator(
{
user: {
subscriptionDate: "2024-03-15",
trialEndDate: "2024-09-30",
},
},
"subscription_flag",
);
expect(res).toEqual({
flagKey: "subscription_flag",
value: "time_sensitive",
context: {
"user.subscriptionDate": "2024-03-15",
"user.trialEndDate": "2024-09-30",
},
ruleEvaluationResults: [true],
reason: "rule #0 matched",
missingContextFields: [],
});
});
});
});
describe("operator evaluation", () => {
beforeAll(() => {
vi.useFakeTimers().setSystemTime(new Date("2024-01-10"));
});
afterAll(() => {
vi.useRealTimers();
});
const tests = [
["value", "IS", "value", true],
["value", "IS", "wrong value", false],
["value", "IS_NOT", "value", false],
["value", "IS_NOT", "wrong value", true],
["value", "ANY_OF", "value", true],
["value", "ANY_OF", "nope", false],
["value", "NOT_ANY_OF", "value", false],
["value", "NOT_ANY_OF", "nope", true],
["value", "IS_TRUE", "", false],
["value", "IS_FALSE", "", false],
["value", "SET", "", true],
["", "SET", "", false],
["value", "NOT_SET", "", false],
["", "NOT_SET", "", true],
// non numeric values should return false
["value", "GT", "value", false],
["value", "GT", "0", false],
["1", "GT", "0", true],
["2", "GT", "10", false],
["10", "GT", "2", true],
["value", "LT", "value", false],
["value", "LT", "0", false],
["0", "LT", "1", true],
["2", "LT", "10", true],
["10", "LT", "2", false],
["start VALUE end", "CONTAINS", "value", true],
["alue", "CONTAINS", "value", false],
["start VALUE end", "NOT_CONTAINS", "value", false],
["alue", "NOT_CONTAINS", "value", true],
// today is 2024-01-10
// 2024-01-10 - 5 days = 2024-01-05
["2024-01-15", "BEFORE", "5", false], // 2024-01-15 is before 2024-01-05 = false
["2024-01-15", "AFTER", "5", true], // 2024-01-15 is after 2024-01-05 = true
["2024-01-01", "BEFORE", "5", true], // 2024-01-01 is before 2024-01-05 = true
["2024-01-01", "AFTER", "5", false], // 2024-01-01 is after 2024-01-05 = false
] as const;
for (const [value, op, filterValue, expected] of tests) {
it(`evaluates '${value}' ${op} 2024-01-10 minus ${filterValue} days = ${expected}`, () => {
const res = evaluate(value, op, [filterValue]);
expect(res).toEqual(expected);
});
}
describe("DATE_AFTER and DATE_BEFORE operators", () => {
const dateTests = [
// DATE_AFTER tests
["2024-01-15", "DATE_AFTER", "2024-01-10", true], // After
["2024-01-10", "DATE_AFTER", "2024-01-10", true], // Same date (>=)
["2024-01-05", "DATE_AFTER", "2024-01-10", false], // Before
["2024-12-31", "DATE_AFTER", "2024-01-01", true], // Much later
["2023-01-01", "DATE_AFTER", "2024-01-01", false], // Much earlier
// DATE_BEFORE tests
["2024-01-05", "DATE_BEFORE", "2024-01-10", true], // Before
["2024-01-10", "DATE_BEFORE", "2024-01-10", true], // Same date (<=)
["2024-01-15", "DATE_BEFORE", "2024-01-10", false], // After
["2023-01-01", "DATE_BEFORE", "2024-01-01", true], // Much earlier
["2024-12-31", "DATE_BEFORE", "2024-01-01", false], // Much later
// Edge cases with different date formats
["2024-01-10T10:30:00Z", "DATE_AFTER", "2024-01-10T10:00:00Z", true], // ISO format with time
["2024-01-10T09:30:00Z", "DATE_BEFORE", "2024-01-10T10:00:00Z", true], // ISO format with time
[
"2024-01-10T10:30:00.123Z",
"DATE_AFTER",
"2024-01-10T10:00:00.000Z",
true,
], // ISO format with time and milliseconds
[
"2024-01-10T09:30:00.123Z",
"DATE_BEFORE",
"2024-01-10T10:00:00.000Z",
true,
], // ISO format with time and milliseconds
["01/15/2024", "DATE_AFTER", "01/10/2024", true], // US format
["01/05/2024", "DATE_BEFORE", "01/10/2024", true], // US format
] as const;
for (const [fieldValue, operator, filterValue, expected] of dateTests) {
it(`evaluates '${fieldValue}' ${operator} '${filterValue}' = ${expected}`, () => {
const res = evaluate(fieldValue, operator, [filterValue]);
expect(res).toEqual(expected);
});
}
it("handles invalid date formats gracefully", () => {
// Invalid dates should result in NaN comparisons and return false
expect(evaluate("invalid-date", "DATE_AFTER", ["2024-01-10"])).toBe(
false,
);
expect(evaluate("2024-01-10", "DATE_AFTER", ["invalid-date"])).toBe(
false,
);
expect(evaluate("invalid-date", "DATE_BEFORE", ["2024-01-10"])).toBe(
false,
);
expect(evaluate("2024-01-10", "DATE_BEFORE", ["invalid-date"])).toBe(
false,
);
});
});
});
describe("rollout hash", () => {
const tests = [
["EEuoT8KShb", 38026],
["h7BOkvks5W", 81440],
["IZeSn3LCfJ", 80149],
["jxYGR0k2eG", 70348],
["VnaiKHgo1E", 82432],
["I3R27J9tGN", 88564],
["JoCeRRF5wm", 67104],
["D9yQyxGKlc", 90226],
["gvfTO4h4Je", 98400],
["zF5iPhvJuw", 53236],
["jMBqhV9Lzr", 99182],
["HQtiM6m2sM", 22123],
["O4VD9CdVMq", 72700],
["lEI48g7tLX", 46266],
["s7sOvfaOQ3", 57198],
["WuCAxrsjwT", 12755],
["1UIruKyifl", 50838],
["f8Y0N3i97C", 42372],
["rA57gcwaXG", 44337],
["5zNThaRQuB", 33221],
["uLIHKFgFU2", 49832],
["Dq29RMUKnK", 75136],
["pNIWi69N81", 21686],
["2lJMZxGGwf", 7747],
["vJHqCdZmo5", 11319],
["qgDRZ2LFvu", 91245],
["iWSiN2Jcad", 13365],
["FTCF9ZRnIY", 65642],
["WxsLfsrQNw", 41778],
["9HgMS79hrG", 88627],
["BXrIz1JIiP", 44341],
["oMtRltWl6T", 85415],
["FKP9myTjTo", 5059],
["fqlZoZ4PhD", 91346],
["ohtHmrXWOB", 45678],
["X7xh1uYeTU", 96239],
["zXe7HkAtjC", 25732],
["AnAZ1gugGv", 62481],
["0mfxv840GT", 27268],
["eins7hyIvx", 70954],
["es9Wkj86PO", 48575],
["g3AZn8zuTe", 44126],
["NHzNfl4ABW", 63844],
["0JZw2gHPg2", 53707],
["GKHMJ46sT9", 17572],
["ZHEpl9s0kN", 59526],
["wSMTYbrr75", 26396],
["0WEJv16LYd", 94865],
["dxV85hJ5t3", 96945],
["00d1uypkKy", 38988],
] as const;
for (const [input, expected] of tests) {
it(`evaluates '${input}' = ${expected}`, () => {
const res = hashInt(input);
expect(res).toEqual(expected);
});
}
});
describe("flattenJSON", () => {
it("should handle an empty object correctly", () => {
const input = {};
const output = flattenJSON(input);
expect(output).toEqual({});
});
it("should flatten a simple object", () => {
const input = {
a: {
b: "value",
},
};
const output = flattenJSON(input);
expect(output).toEqual({
"a.b": "value",
});
});
it("should flatten nested objects", () => {
const input = {
a: {
b: {
c: {
d: "value",
},
},
},
};
const output = flattenJSON(input);
expect(output).toEqual({
"a.b.c.d": "value",
});
});
it("should handle mixed data types", () => {
const input = {
a: {
b: "string",
c: 123,
d: true,
},
};
const output = flattenJSON(input);
expect(output).toEqual({
"a.b": "string",
"a.c": "123",
"a.d": "true",
});
});
it("should flatten arrays", () => {
const input = {
a: ["value1", "value2", "value3"],
};
const output = flattenJSON(input);
expect(output).toEqual({
"a.0": "value1",
"a.1": "value2",
"a.2": "value3",
});
});
it("should handle empty arrays", () => {
const input = {
a: [],
};
const output = flattenJSON(input);
expect(output).toEqual({
a: "",
});
});
it("should correctly flatten mixed structures involving arrays and objects", () => {
const input = {
a: {
b: ["value1", { nested: "value2" }, "value3"],
},
};
const output = flattenJSON(input);
expect(output).toEqual({
"a.b.0": "value1",
"a.b.1.nested": "value2",
"a.b.2": "value3",
});
});
it("should flatten deeply nested objects", () => {
const input = {
level1: {
level2: {
level3: {
key: "value",
anotherKey: "anotherValue",
},
},
singleKey: "test",
},
};
const output = flattenJSON(input);
expect(output).toEqual({
"level1.level2.level3.key": "value",
"level1.level2.level3.anotherKey": "anotherValue",
"level1.singleKey": "test",
});
});
it("should handle objects with empty values", () => {
const input = {
a: {
b: "",
},
};
const output = flattenJSON(input);
expect(output).toEqual({
"a.b": "",
});
});
it("should handle null values", () => {
const input = {
a: null,
b: {
c: null,
},
};
const output = flattenJSON(input);
expect(output).toEqual({
a: "",
"b.c": "",
});
});
it("should skip undefined values", () => {
const input = {
a: "value",
b: undefined,
c: {
d: undefined,
e: "another value",
},
};
const output = flattenJSON(input);
expect(output).toEqual({
a: "value",
"c.e": "another value",
});
});
it("should handle empty nested objects", () => {
const input = {
a: {},
b: {
c: {},
d: "value",
},
};
const output = flattenJSON(input);
expect(output).toEqual({
a: "",
"b.c": "",
"b.d": "value",
});
});
it("should handle top-level primitive values", () => {
const input = {
a: "simple",
b: 42,
c: true,
d: false,
};
const output = flattenJSON(input);
expect(output).toEqual({
a: "simple",
b: "42",
c: "true",
d: "false",
});
});
it("should handle arrays with null and undefined values", () => {
const input = {
a: ["value1", null, undefined, "value4"],
};
const output = flattenJSON(input);
expect(output).toEqual({
"a.0": "value1",
"a.1": "",
"a.3": "value4",
});
});
it("should handle deeply nested empty structures", () => {
const input = {
a: {
b: {
c: {},
d: [],
},
},
};
const output = flattenJSON(input);
expect(output).toEqual({
"a.b.c": "",
"a.b.d": "",
});
});
it("should handle keys with special characters", () => {
const input = {
"key.with.dots": "value1",
"key-with-dashes": "value2",
"key with spaces": "value3",
};
const output = flattenJSON(input);
expect(output).toEqual({
"key.with.dots": "value1",
"key-with-dashes": "value2",
"key with spaces": "value3",
});
});
it("should handle edge case numbers and booleans", () => {
const input = {
zero: 0,
negativeNumber: -42,
float: 3.14,
infinity: Infinity,
negativeInfinity: -Infinity,
nan: NaN,
falseValue: false,
};
const output = flattenJSON(input);
expect(output).toEqual({
zero: "0",
negativeNumber: "-42",
float: "3.14",
infinity: "Infinity",
negativeInfinity: "-Infinity",
nan: "NaN",
falseValue: "false",
});
});
});
describe("unflattenJSON", () => {
it("should handle an empty object correctly", () => {
const input = {};
const output = unflattenJSON(input);
expect(output).toEqual({});
});
it("should convert a flat object with one level deep keys to a nested object", () => {
const input = {
"a.b.c": "value",
"x.y": "anotherValue",
};
const output = unflattenJSON(input);
expect(output).toEqual({
a: {
b: { c: "value" },
},
x: {
y: "anotherValue",
},
});
});
it("should not handle arrays properly", () => {
const input = {
"arr.0": "first",
"arr.1": "second",
"arr.2": "third",
};
const output = unflattenJSON(input);
expect(output).toEqual({
arr: {
"0": "first",
"1": "second",
"2": "third",
},
});
});
it("should handle mixed data types in flat JSON", () => {
const input = {
"a.b": "string",
"a.c": 123,
"a.d": true,
};
const output = unflattenJSON(input);
expect(output).toEqual({
a: {
b: "string",
c: 123,
d: true,
},
});
});
it("should correctly handle scenarios with overlapping keys (ignore)", () => {
const input = {
"a.b": "value1",
"a.b.c": "value2",
};
const output = unflattenJSON(input);
expect(output).toEqual({ a: { b: "value1" } });
});
it("should unflatten nested objects correctly", () => {
const input = {
"level1.level2.level3": "deepValue",
"level1.level2.key": 10,
"level1.singleKey": "test",
};
const output = unflattenJSON(input);
expect(output).toEqual({
level1: {
level2: {
level3: "deepValue",
key: 10,
},
singleKey: "test",
},
});
});
it("should handle a scenario where a key is an empty string", () => {
const input = {
"": "rootValue",
};
const output = unflattenJSON(input);
expect(output).toEqual({
"": "rootValue",
});
});
});
```
--------------------------------------------------------------------------------
/packages/react-sdk/test/usage.test.tsx:
--------------------------------------------------------------------------------
```typescript
import React from "react";
import { render, renderHook, waitFor } from "@testing-library/react";
import { http, HttpResponse } from "msw";
import { setupServer } from "msw/node";
import {
afterAll,
afterEach,
beforeAll,
beforeEach,
describe,
expect,
test,
vi,
} from "vitest";
import { ReflagClient } from "@reflag/browser-sdk";
import {
BootstrappedFlags,
ReflagBootstrappedProps,
ReflagBootstrappedProvider,
ReflagClientProvider,
ReflagProps,
ReflagProvider,
useClient,
useFlag,
useIsLoading,
useOnEvent,
useRequestFeedback,
useSendFeedback,
useTrack,
useUpdateCompany,
useUpdateOtherContext,
useUpdateUser,
} from "../src";
const events: string[] = [];
const originalConsoleError = console.error.bind(console);
afterEach(() => {
events.length = 0;
console.error = originalConsoleError;
});
const company = { id: "123", name: "test" };
const user = { id: "456", name: "test" };
const other = { test: "test" };
let keyIndex = 0;
function getProvider(props: Omit<Partial<ReflagProps>, "publishableKey"> = {}) {
const publishableKey = `KEY-${keyIndex++}`;
return (
<ReflagProvider
context={{ user, company, other }}
publishableKey={publishableKey}
{...props}
/>
);
}
function getBootstrapProvider(
bootstrapFlags: BootstrappedFlags,
props: Omit<Partial<ReflagBootstrappedProps>, "publishableKey"> = {},
) {
const publishableKey = `KEY-${keyIndex++}`;
return (
<ReflagBootstrappedProvider
flags={bootstrapFlags}
publishableKey={publishableKey}
{...props}
/>
);
}
const server = setupServer(
http.post(/\/event$/, () => {
events.push("EVENT");
return new HttpResponse(
JSON.stringify({
success: true,
}),
{ status: 200 },
);
}),
http.post(/\/feedback$/, () => {
events.push("FEEDBACK");
return new HttpResponse(
JSON.stringify({
success: true,
}),
{ status: 200 },
);
}),
http.get(/\/features\/evaluated$/, () => {
return new HttpResponse(
JSON.stringify({
success: true,
features: {
abc: {
key: "abc",
isEnabled: true,
targetingVersion: 1,
config: {
key: "gpt3",
payload: { model: "gpt-something", temperature: 0.5 },
version: 2,
},
},
def: {
key: "def",
isEnabled: true,
targetingVersion: 2,
},
},
}),
{ status: 200 },
);
}),
http.post(/\/user$/, () => {
return new HttpResponse(
JSON.stringify({
success: true,
}),
{ status: 200 },
);
}),
http.post(/\/company$/, () => {
return new HttpResponse(
JSON.stringify({
success: true,
}),
{ status: 200 },
);
}),
http.post(/feedback\/prompting-init$/, () => {
return new HttpResponse(
JSON.stringify({
success: false,
}),
{ status: 200 },
);
}),
http.post(/\/features\/events$/, () => {
return new HttpResponse(
JSON.stringify({
success: false,
}),
{ status: 200 },
);
}),
);
beforeAll(() =>
server.listen({
onUnhandledRequest(request) {
console.error("Unhandled %s %s", request.method, request.url);
},
}),
);
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
beforeAll(() => {
vi.spyOn(ReflagClient.prototype, "initialize");
vi.spyOn(ReflagClient.prototype, "stop");
});
beforeEach(() => {
vi.clearAllMocks();
});
describe("<ReflagProvider />", () => {
test("calls initialize", () => {
const initialize = vi.spyOn(ReflagClient.prototype, "initialize");
const provider = getProvider({
apiBaseUrl: "https://apibaseurl.com",
sseBaseUrl: "https://ssebaseurl.com",
context: {
user: { id: "456", name: "test" },
company: { id: "123", name: "test" },
other: { test: "test" },
},
enableTracking: false,
appBaseUrl: "https://appbaseurl.com",
staleTimeMs: 1001,
timeoutMs: 1002,
expireTimeMs: 1003,
staleWhileRevalidate: true,
fallbackFlags: ["flag2"],
feedback: { enableAutoFeedback: true },
toolbar: { show: true },
});
render(provider);
expect(initialize).toHaveBeenCalled();
});
test("only calls init once with the same args", () => {
const node = getProvider();
const initialize = vi.spyOn(ReflagClient.prototype, "initialize");
const x = render(node);
x.rerender(node);
x.rerender(node);
x.rerender(node);
expect(initialize).toHaveBeenCalledOnce();
expect(ReflagClient.prototype.stop).not.toHaveBeenCalledOnce();
});
test("handles context changes", async () => {
const { queryByTestId, rerender } = render(
getProvider({
loadingComponent: <span data-testid="loading">Loading...</span>,
children: <span data-testid="content">Content</span>,
}),
);
// Wait for content to be visible
await waitFor(() => {
expect(queryByTestId("content")).not.toBeNull();
});
// Change user context
rerender(
getProvider({
loadingComponent: <span data-testid="loading">Loading...</span>,
user: { ...user, id: "new-user-id" },
children: <span data-testid="content">Content</span>,
}),
);
// Content should still be visible
await waitFor(() => {
expect(queryByTestId("content")).not.toBeNull();
});
// Change company context
rerender(
getProvider({
loadingComponent: <span data-testid="loading">Loading...</span>,
company: { ...company, id: "new-company-id" },
children: <span data-testid="content">Content</span>,
}),
);
// Content should still be visible
await waitFor(() => {
expect(queryByTestId("content")).not.toBeNull();
});
});
});
describe("useFlag", () => {
test("returns a loading state initially", async () => {
const { result, unmount } = renderHook(() => useFlag("huddle"), {
wrapper: ({ children }) => getProvider({ children }),
});
// The flag should exist but may be loading or not depending on implementation
expect(result.current.key).toBe("huddle");
expect(result.current.isEnabled).toBe(false);
expect(result.current.config).toEqual({
key: undefined,
payload: undefined,
});
expect(typeof result.current.track).toBe("function");
expect(typeof result.current.requestFeedback).toBe("function");
unmount();
});
test("finishes loading", async () => {
const { result, unmount } = renderHook(() => useFlag("huddle"), {
wrapper: ({ children }) => getProvider({ children }),
});
await waitFor(() => {
expect(result.current).toStrictEqual({
key: "huddle",
config: { key: undefined, payload: undefined },
isEnabled: false,
isLoading: false,
track: expect.any(Function),
requestFeedback: expect.any(Function),
});
});
unmount();
});
test("provides the expected values if flag is enabled", async () => {
const { result, unmount } = renderHook(() => useFlag("abc"), {
wrapper: ({ children }) => getProvider({ children }),
});
await waitFor(() => {
expect(result.current).toStrictEqual({
key: "abc",
isEnabled: true,
isLoading: false,
config: {
key: "gpt3",
payload: { model: "gpt-something", temperature: 0.5 },
},
track: expect.any(Function),
requestFeedback: expect.any(Function),
});
});
unmount();
});
});
describe("useTrack", () => {
test("sends track request", async () => {
const { result, unmount } = renderHook(() => useTrack(), {
wrapper: ({ children }) => getProvider({ children }),
});
await waitFor(async () => {
await result.current("event", { test: "test" });
expect(events).toStrictEqual(["EVENT"]);
});
unmount();
});
});
describe("useSendFeedback", () => {
test("sends feedback", async () => {
const { result, unmount } = renderHook(() => useSendFeedback(), {
wrapper: ({ children }) => getProvider({ children }),
});
await waitFor(async () => {
await result.current({
flagKey: "huddles",
score: 5,
});
expect(events).toStrictEqual(["FEEDBACK"]);
});
unmount();
});
});
describe("useRequestFeedback", () => {
test("sends feedback", async () => {
const requestFeedback = vi
.spyOn(ReflagClient.prototype, "requestFeedback")
.mockReturnValue(undefined);
const { result, unmount } = renderHook(() => useRequestFeedback(), {
wrapper: ({ children }) => getProvider({ children }),
});
await waitFor(async () => {
result.current({
flagKey: "huddles",
title: "Test question",
companyId: "456",
});
expect(requestFeedback).toHaveBeenCalledOnce();
expect(requestFeedback).toHaveBeenCalledWith({
flagKey: "huddles",
companyId: "456",
title: "Test question",
});
});
unmount();
});
});
describe("useUpdateUser", () => {
test("updates user", async () => {
const updateUser = vi
.spyOn(ReflagClient.prototype, "updateUser")
.mockResolvedValue(undefined);
const { result: updateUserFn, unmount } = renderHook(
() => useUpdateUser(),
{
wrapper: ({ children }) => getProvider({ children }),
},
);
// todo: need this `waitFor` because useUpdateOtherContext
// runs before `client` is initialized and then the call gets
// lost.
await waitFor(async () => {
await updateUserFn.current({ optInHuddles: "true" });
expect(updateUser).toHaveBeenCalledWith({
optInHuddles: "true",
});
});
unmount();
});
});
describe("useUpdateCompany", () => {
test("updates company", async () => {
const updateCompany = vi
.spyOn(ReflagClient.prototype, "updateCompany")
.mockResolvedValue(undefined);
const { result: updateCompanyFn, unmount } = renderHook(
() => useUpdateCompany(),
{
wrapper: ({ children }) => getProvider({ children }),
},
);
// todo: need this `waitFor` because useUpdateOtherContext
// runs before `client` is initialized and then the call gets
// lost.
await waitFor(async () => {
await updateCompanyFn.current({ optInHuddles: "true" });
expect(updateCompany).toHaveBeenCalledWith({
optInHuddles: "true",
});
});
unmount();
});
});
describe("useUpdateOtherContext", () => {
test("updates other context", async () => {
const updateOtherContext = vi
.spyOn(ReflagClient.prototype, "updateOtherContext")
.mockResolvedValue(undefined);
const { result: updateOtherContextFn, unmount } = renderHook(
() => useUpdateOtherContext(),
{
wrapper: ({ children }) => getProvider({ children }),
},
);
// todo: need this `waitFor` because useUpdateOtherContext
// runs before `client` is initialized and then the call gets
// lost.
await waitFor(async () => {
await updateOtherContextFn.current({ optInHuddles: "true" });
expect(updateOtherContext).toHaveBeenCalledWith({
optInHuddles: "true",
});
});
unmount();
});
});
describe("useClient", () => {
test("gets the client", async () => {
const { result: clientFn, unmount } = renderHook(() => useClient(), {
wrapper: ({ children }) => getProvider({ children }),
});
await waitFor(async () => {
expect(clientFn.current).toBeDefined();
});
unmount();
});
});
describe("<ReflagBootstrappedProvider />", () => {
test("renders with pre-fetched flags", () => {
const bootstrapFlags: BootstrappedFlags = {
context: {
user: { id: "456", name: "test" },
company: { id: "123", name: "test" },
other: { test: "test" },
},
flags: {
abc: {
key: "abc",
isEnabled: true,
targetingVersion: 1,
config: {
key: "gpt3",
payload: { model: "gpt-something", temperature: 0.5 },
version: 2,
},
},
def: {
key: "def",
isEnabled: true,
targetingVersion: 2,
},
},
};
const { container } = render(
getBootstrapProvider(bootstrapFlags, {
apiBaseUrl: "https://apibaseurl.com",
sseBaseUrl: "https://ssebaseurl.com",
enableTracking: false,
appBaseUrl: "https://appbaseurl.com",
staleTimeMs: 1001,
timeoutMs: 1002,
expireTimeMs: 1003,
staleWhileRevalidate: true,
fallbackFlags: ["flag2"],
feedback: { enableAutoFeedback: true },
toolbar: { show: true },
children: <span>Test Content</span>,
}),
);
expect(container).toBeDefined();
});
test("renders in bootstrap mode", () => {
const bootstrapFlags: BootstrappedFlags = {
context: {
user: { id: "456", name: "test" },
company: { id: "123", name: "test" },
other: { test: "test" },
},
flags: {
abc: {
key: "abc",
isEnabled: true,
targetingVersion: 1,
},
},
};
const { container } = render(
getBootstrapProvider(bootstrapFlags, {
children: <span>Bootstrap Content</span>,
}),
);
expect(container).toBeDefined();
});
// Removed test "does not initialize when no flags are provided"
// because ReflagBootstrappedProvider requires flags to be provided
test("shows content after initialization", async () => {
const bootstrapFlags: BootstrappedFlags = {
context: {
user: { id: "456", name: "test" },
company: { id: "123", name: "test" },
other: { test: "test" },
},
flags: {
abc: {
key: "abc",
isEnabled: true,
targetingVersion: 1,
},
},
};
const { container } = render(
getBootstrapProvider(bootstrapFlags, {
loadingComponent: <span data-testid="loading">Loading...</span>,
children: <span data-testid="bootstrap-content">Content</span>,
}),
);
// Content should eventually be visible
await waitFor(() => {
expect(
container.querySelector('[data-testid="bootstrap-content"]'),
).not.toBeNull();
});
});
// Removed test "shows loading component when no flags are provided"
// because ReflagBootstrappedProvider requires flags to be provided
});
describe("useFlag with ReflagBootstrappedProvider", () => {
test("returns bootstrapped flag values", async () => {
const bootstrapFlags: BootstrappedFlags = {
context: {
user: { id: "456", name: "test" },
company: { id: "123", name: "test" },
other: { test: "test" },
},
flags: {
abc: {
key: "abc",
isEnabled: true,
targetingVersion: 1,
config: {
key: "gpt3",
payload: { model: "gpt-something", temperature: 0.5 },
version: 2,
},
},
def: {
key: "def",
isEnabled: true,
targetingVersion: 2,
},
},
};
const { result, unmount } = renderHook(() => useFlag("abc"), {
wrapper: ({ children }) =>
getBootstrapProvider(bootstrapFlags, { children }),
});
await waitFor(() => {
expect(result.current).toStrictEqual({
key: "abc",
isEnabled: true,
isLoading: false,
config: {
key: "gpt3",
payload: { model: "gpt-something", temperature: 0.5 },
},
track: expect.any(Function),
requestFeedback: expect.any(Function),
});
});
unmount();
});
test("returns disabled flag for non-existent flags", async () => {
const bootstrapFlags: BootstrappedFlags = {
context: {
user: { id: "456", name: "test" },
company: { id: "123", name: "test" },
other: { test: "test" },
},
flags: {
abc: {
key: "abc",
isEnabled: true,
targetingVersion: 1,
},
},
};
const { result, unmount } = renderHook(() => useFlag("nonexistent"), {
wrapper: ({ children }) =>
getBootstrapProvider(bootstrapFlags, { children }),
});
await waitFor(() => {
expect(result.current).toStrictEqual({
key: "nonexistent",
isEnabled: false,
isLoading: false,
config: {
key: undefined,
payload: undefined,
},
track: expect.any(Function),
requestFeedback: expect.any(Function),
});
});
unmount();
});
// Removed test "returns loading state when no flags are bootstrapped"
// because ReflagBootstrappedProvider requires flags to be provided
});
describe("<ReflagClientProvider />", () => {
test("renders with external client and optional loadingComponent", async () => {
const client = new ReflagClient({
publishableKey: "test-key",
user,
company,
other,
});
const { container } = render(
<ReflagClientProvider client={client}>
<span data-testid="content">Test Content</span>
</ReflagClientProvider>,
);
expect(container.querySelector('[data-testid="content"]')).not.toBeNull();
});
test("renders with external client and loadingComponent", async () => {
const client = new ReflagClient({
publishableKey: "test-key",
user,
company,
other,
});
const { container } = render(
<ReflagClientProvider
client={client}
loadingComponent={<span data-testid="loading">Loading...</span>}
>
<span data-testid="content">Test Content</span>
</ReflagClientProvider>,
);
// Initially may show loading or content depending on client state
expect(container).toBeDefined();
});
test("provides client to child components", async () => {
const client = new ReflagClient({
publishableKey: "test-key",
user,
company,
other,
});
const { result, unmount } = renderHook(() => useClient(), {
wrapper: ({ children }) => (
<ReflagClientProvider client={client}>{children}</ReflagClientProvider>
),
});
expect(result.current).toBe(client);
// Verify that the external client maintains its context
const context = result.current.getContext();
expect(context.user).toEqual(user);
expect(context.company).toEqual(company);
expect(context.other).toEqual(other);
unmount();
});
test("handles client state changes", async () => {
const client = new ReflagClient({
publishableKey: "test-key-state-changes",
user,
company,
other,
});
const { container } = render(
<ReflagClientProvider
client={client}
loadingComponent={<span data-testid="client-loading">Loading...</span>}
>
<span data-testid="client-content">Content</span>
</ReflagClientProvider>,
);
// The component should handle state changes properly
expect(
container.querySelector('[data-testid="client-content"]') ||
container.querySelector('[data-testid="client-loading"]'),
).not.toBeNull();
});
test("works with useFlag hook", async () => {
const client = new ReflagClient({
publishableKey: "test-key",
user,
company,
other,
});
const { result, unmount } = renderHook(() => useFlag("test-flag"), {
wrapper: ({ children }) => (
<ReflagClientProvider client={client}>{children}</ReflagClientProvider>
),
});
expect(result.current.key).toBe("test-flag");
expect(typeof result.current.track).toBe("function");
expect(typeof result.current.requestFeedback).toBe("function");
unmount();
});
});
describe("ReflagProvider with deprecated properties", () => {
test("works with deprecated user property", async () => {
const deprecatedUser = { id: "deprecated-user", name: "Deprecated User" };
const { result, unmount } = renderHook(() => useClient(), {
wrapper: ({ children }) => (
<ReflagProvider
context={{}}
publishableKey="test-key-1"
user={deprecatedUser}
>
{children}
</ReflagProvider>
),
});
await waitFor(() => {
expect(result.current).toBeDefined();
const context = result.current.getContext();
expect(context.user).toEqual(deprecatedUser);
expect(context.company).toBeUndefined();
expect(context.other).toEqual({});
});
unmount();
});
test("works with deprecated company property", async () => {
const deprecatedCompany = {
id: "deprecated-company",
name: "Deprecated Company",
};
const { result, unmount } = renderHook(() => useClient(), {
wrapper: ({ children }) => (
<ReflagProvider
company={deprecatedCompany}
context={{}}
publishableKey="test-key-2"
>
{children}
</ReflagProvider>
),
});
await waitFor(() => {
expect(result.current).toBeDefined();
const context = result.current.getContext();
expect(context.company).toEqual(deprecatedCompany);
expect(context.user).toBeUndefined();
expect(context.other).toEqual({});
});
unmount();
});
test("works with deprecated otherContext property", async () => {
const deprecatedOtherContext = { workspace: "deprecated-workspace" };
const { result, unmount } = renderHook(() => useClient(), {
wrapper: ({ children }) => (
<ReflagProvider
context={{}}
otherContext={deprecatedOtherContext}
publishableKey="test-key-3"
>
{children}
</ReflagProvider>
),
});
await waitFor(() => {
expect(result.current).toBeDefined();
const context = result.current.getContext();
expect(context.other).toEqual(deprecatedOtherContext);
expect(context.user).toBeUndefined();
expect(context.company).toBeUndefined();
});
unmount();
});
test("context property overrides deprecated properties", async () => {
const contextUser = { id: "context-user", name: "Context User" };
const contextCompany = { id: "context-company", name: "Context Company" };
const contextOther = { workspace: "context-workspace" };
const deprecatedUser = { id: "deprecated-user", name: "Deprecated User" };
const deprecatedCompany = {
id: "deprecated-company",
name: "Deprecated Company",
};
const deprecatedOtherContext = { workspace: "deprecated-workspace" };
const { result, unmount } = renderHook(() => useClient(), {
wrapper: ({ children }) => (
<ReflagProvider
company={deprecatedCompany}
context={{
user: contextUser,
company: contextCompany,
other: contextOther,
}}
otherContext={deprecatedOtherContext}
publishableKey="test-key-4"
user={deprecatedUser}
>
{children}
</ReflagProvider>
),
});
await waitFor(() => {
expect(result.current).toBeDefined();
const context = result.current.getContext();
// The context property should override deprecated properties
expect(context.user).toEqual(contextUser);
expect(context.company).toEqual(contextCompany);
expect(context.other).toEqual(contextOther);
});
unmount();
});
test("merges deprecated properties with context", async () => {
const contextUser = { id: "context-user", email: "[email protected]" };
const deprecatedUser = { id: "deprecated-user", name: "Deprecated User" };
const deprecatedCompany = {
id: "deprecated-company",
name: "Deprecated Company",
};
const { result, unmount } = renderHook(() => useClient(), {
wrapper: ({ children }) => (
<ReflagProvider
company={deprecatedCompany}
context={{
user: contextUser,
}}
publishableKey="test-key-5"
user={deprecatedUser}
>
{children}
</ReflagProvider>
),
});
await waitFor(() => {
expect(result.current).toBeDefined();
const context = result.current.getContext();
// The context user should override the deprecated user,
// but deprecated company should still be present
expect(context.user).toEqual(contextUser);
expect(context.company).toEqual(deprecatedCompany);
expect(context.other).toEqual({});
});
unmount();
});
test("handles all deprecated properties together", async () => {
const deprecatedUser = { id: "deprecated-user", name: "Deprecated User" };
const deprecatedCompany = {
id: "deprecated-company",
name: "Deprecated Company",
};
const deprecatedOtherContext = {
workspace: "deprecated-workspace",
feature: "test",
};
const { result, unmount } = renderHook(() => useClient(), {
wrapper: ({ children }) => (
<ReflagProvider
company={deprecatedCompany}
context={{}}
otherContext={deprecatedOtherContext}
publishableKey="test-key-6"
user={deprecatedUser}
>
{children}
</ReflagProvider>
),
});
await waitFor(() => {
expect(result.current).toBeDefined();
const context = result.current.getContext();
// All deprecated properties should be properly set
expect(context.user).toEqual(deprecatedUser);
expect(context.company).toEqual(deprecatedCompany);
expect(context.other).toEqual(deprecatedOtherContext);
});
unmount();
});
});
describe("useIsLoading", () => {
test("returns loading state during initialization", async () => {
const { result, unmount } = renderHook(() => useIsLoading(), {
wrapper: ({ children }) => getProvider({ children }),
});
// Should be loading initially
expect(result.current).toBe(true);
// Wait for initialization to complete
await waitFor(() => {
expect(result.current).toBe(false);
});
unmount();
});
test("throws error when used outside provider", () => {
const consoleErrorSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {
// Silence console.error during test
});
expect(() => {
renderHook(() => useIsLoading());
}).toThrow(
"ReflagProvider is missing. Please ensure your component is wrapped with a ReflagProvider.",
);
consoleErrorSpy.mockRestore();
});
});
describe("useOnEvent", () => {
test("subscribes to flagsUpdated event", async () => {
const eventHandler = vi.fn();
const client = new ReflagClient({
publishableKey: "test-key-events",
user,
company,
other,
});
const { unmount } = renderHook(
() => useOnEvent("flagsUpdated", eventHandler),
{
wrapper: ({ children }) => (
<ReflagClientProvider client={client}>
{children}
</ReflagClientProvider>
),
},
);
// Initialize the client to trigger events
await client.initialize();
// Wait for the event to be triggered
await waitFor(() => {
expect(eventHandler).toHaveBeenCalled();
});
unmount();
});
test("works with external client parameter", async () => {
const eventHandler = vi.fn();
const client = new ReflagClient({
publishableKey: "test-key-external",
user,
company,
other,
});
const { unmount } = renderHook(() =>
useOnEvent("flagsUpdated", eventHandler, client),
);
// Initialize the client to trigger events
await client.initialize();
// Wait for the event to be triggered
await waitFor(() => {
expect(eventHandler).toHaveBeenCalled();
});
unmount();
});
test("cleans up event listeners on unmount", async () => {
const eventHandler = vi.fn();
const client = new ReflagClient({
publishableKey: "test-key-cleanup",
user,
company,
other,
});
// Mock the addHook method to return a cleanup function that we can spy on
const cleanupSpy = vi.fn();
const addHookSpy = vi
.spyOn(client["hooks"], "addHook")
.mockReturnValue(cleanupSpy);
const { unmount } = renderHook(
() => useOnEvent("flagsUpdated", eventHandler),
{
wrapper: ({ children }) => (
<ReflagClientProvider client={client}>
{children}
</ReflagClientProvider>
),
},
);
// Verify that addHook was called with the correct parameters
expect(addHookSpy).toHaveBeenCalledWith("flagsUpdated", eventHandler);
unmount();
// Verify that the cleanup function was called
expect(cleanupSpy).toHaveBeenCalled();
addHookSpy.mockRestore();
});
test("throws error when used outside provider without client parameter", () => {
const consoleErrorSpy = vi
.spyOn(console, "error")
.mockImplementation(() => {
// Silence console.error during test
});
const eventHandler = vi.fn();
expect(() => {
renderHook(() => useOnEvent("flagsUpdated", eventHandler));
}).toThrow(
"ReflagProvider is missing and no client was provided. Please ensure your component is wrapped with a ReflagProvider.",
);
consoleErrorSpy.mockRestore();
});
test("handles multiple event subscriptions", async () => {
const flagsHandler = vi.fn();
const stateHandler = vi.fn();
const client = new ReflagClient({
publishableKey: "test-key-multiple",
user,
company,
other,
});
const { unmount } = renderHook(
() => {
useOnEvent("flagsUpdated", flagsHandler);
useOnEvent("stateUpdated", stateHandler);
},
{
wrapper: ({ children }) => (
<ReflagClientProvider client={client}>
{children}
</ReflagClientProvider>
),
},
);
// Initialize the client to trigger events
await client.initialize();
// Wait for both events to be triggered
await waitFor(() => {
expect(flagsHandler).toHaveBeenCalled();
expect(stateHandler).toHaveBeenCalled();
});
unmount();
});
});
```