#
tokens: 44488/50000 8/327 files (page 6/9)
lines: on (toggle) GitHub
raw markdown copy reset
This is page 6 of 9. Use http://codebase.md/bucketco/bucket-javascript-sdk?lines=true&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/test/e2e/feedback-widget.browser.spec.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { randomUUID } from "crypto";
  2 | import { expect, Locator, Page, test } from "@playwright/test";
  3 | 
  4 | import { InitOptions } from "../../src/client";
  5 | import { DEFAULT_TRANSLATIONS } from "../../src/feedback/ui/config/defaultTranslations";
  6 | import { FeedbackTranslations } from "../../src/feedback/ui/types";
  7 | import { feedbackContainerId, propagatedEvents } from "../../src/ui/constants";
  8 | 
  9 | const KEY = randomUUID();
 10 | const API_HOST = `https://front.reflag.com`;
 11 | 
 12 | const WINDOW_WIDTH = 1280;
 13 | const WINDOW_HEIGHT = 720;
 14 | 
 15 | declare global {
 16 |   interface Window {
 17 |     eventsFired: Record<string, boolean>;
 18 |   }
 19 | }
 20 | 
 21 | function pick<T>(options: T[]): T {
 22 |   return options[Math.floor(Math.random() * options.length)];
 23 | }
 24 | 
 25 | async function getOpenedWidgetContainer(
 26 |   page: Page,
 27 |   initOptions: Omit<InitOptions, "publishableKey"> = {},
 28 | ) {
 29 |   await page.goto("http://localhost:8001/test/e2e/empty.html");
 30 | 
 31 |   // Mock API calls
 32 |   await page.route(`${API_HOST}/user`, async (route) => {
 33 |     await route.fulfill({ status: 200 });
 34 |   });
 35 | 
 36 |   await page.route(`${API_HOST}/features/evaluated*`, async (route) => {
 37 |     await route.fulfill({
 38 |       status: 200,
 39 |       body: JSON.stringify({
 40 |         success: true,
 41 |         features: {},
 42 |       }),
 43 |     });
 44 |   });
 45 | 
 46 |   // Golden path requests
 47 |   await page.evaluate(`
 48 |     ;(async () => {
 49 |       const { ReflagClient } = await import("/dist/reflag-browser-sdk.mjs");
 50 |       const reflag = new ReflagClient({publishableKey: "${KEY}", user: {id: "foo"}, company: {id: "bar"}, ...${JSON.stringify(initOptions ?? {})}});
 51 |       await reflag.initialize();
 52 |       await reflag.requestFeedback({
 53 |         flagKey: "flag1",
 54 |         title: "baz",
 55 |       });
 56 |     })()
 57 |   `);
 58 | 
 59 |   return page.locator(`#${feedbackContainerId}`);
 60 | }
 61 | 
 62 | async function getGiveFeedbackPageContainer(
 63 |   page: Page,
 64 |   initOptions: Omit<InitOptions, "publishableKey"> = {},
 65 | ) {
 66 |   await page.goto("http://localhost:8001/test/e2e/give-feedback-button.html");
 67 | 
 68 |   // Mock API calls
 69 |   await page.route(`${API_HOST}/user`, async (route) => {
 70 |     await route.fulfill({ status: 200 });
 71 |   });
 72 | 
 73 |   await page.route(`${API_HOST}/features/evaluated*`, async (route) => {
 74 |     await route.fulfill({
 75 |       status: 200,
 76 |       body: JSON.stringify({
 77 |         success: true,
 78 |         features: {},
 79 |       }),
 80 |     });
 81 |   });
 82 | 
 83 |   // Golden path requests
 84 |   await page.evaluate(`
 85 |     ;(async () => {
 86 |       const { ReflagClient } = await import("/dist/reflag-browser-sdk.mjs");
 87 |       const reflag = new ReflagClient({publishableKey: "${KEY}", user: {id: "foo"}, company: {id: "bar"}, ...${JSON.stringify(initOptions ?? {})}});
 88 |       await reflag.initialize();
 89 |       console.log("setup clicky", document.querySelector("#give-feedback-button"))
 90 |       document.querySelector("#give-feedback-button")?.addEventListener("click", () => {
 91 |         console.log("cliked!");
 92 |         reflag.requestFeedback({
 93 |           flagKey: "flag1",
 94 |           title: "baz",
 95 |         });
 96 |       });
 97 |     })()
 98 |   `);
 99 | 
100 |   return page.locator(`#${feedbackContainerId}`);
101 | }
102 | 
103 | async function setScore(container: Locator, score: number) {
104 |   await new Promise((resolve) => setTimeout(resolve, 50)); // allow react to update its state
105 |   await container
106 |     .locator(`#reflag-feedback-score-${score}`)
107 |     .dispatchEvent("click");
108 | }
109 | 
110 | async function setComment(container: Locator, comment: string) {
111 |   await container.locator("#reflag-feedback-comment-label").fill(comment);
112 | }
113 | 
114 | async function submitForm(container: Locator) {
115 |   await container.locator(".form-expanded-content").getByRole("button").click();
116 | }
117 | 
118 | test.beforeEach(async ({ page, browserName }) => {
119 |   // Log any calls to front.reflag.com which aren't mocked by subsequent
120 |   // `page.route` calls. With page.route, the last matching mock takes
121 |   // precedence, so this logs any which may have been missed, and responds
122 |   // with a 200 to prevent an internet request.
123 |   await page.route(/^https:\/\/front\.reflag\.com.*/, async (route) => {
124 |     const meta = `${route.request().method()} ${route.request().url()}`;
125 | 
126 |     console.debug(`\n Unmocked request:        [${browserName}] > ${meta}`);
127 |     console.debug(`Sent stub mock response: [${browserName}] < ${meta} 200\n`);
128 | 
129 |     await route.fulfill({ status: 200, body: "{}" });
130 |   });
131 | 
132 |   // Mock prompting-init as if prompting is `disabled` for all tests.
133 |   await page.route(`${API_HOST}/feedback/prompting-init`, async (route) => {
134 |     await route.fulfill({
135 |       status: 200,
136 |       body: JSON.stringify({ success: false }),
137 |     });
138 |   });
139 | });
140 | 
141 | test("Opens a feedback widget", async ({ page }) => {
142 |   const container = await getOpenedWidgetContainer(page);
143 | 
144 |   await expect(container).toBeAttached();
145 |   await expect(container.locator("dialog")).toHaveAttribute("open", "");
146 | });
147 | 
148 | test("Opens a feedback widget multiple times in same session", async ({
149 |   page,
150 | }) => {
151 |   const container = await getGiveFeedbackPageContainer(page);
152 | 
153 |   await page.getByTestId("give-feedback-button").click();
154 |   await expect(container).toBeAttached();
155 |   await expect(container.locator("dialog")).toHaveAttribute("open", "");
156 | 
157 |   await container.locator("dialog .close").click();
158 |   await expect(container.locator("dialog")).not.toHaveAttribute("open", "");
159 | 
160 |   await page.getByTestId("give-feedback-button").click();
161 |   await expect(container).toBeAttached();
162 |   await expect(container.locator("dialog")).toHaveAttribute("open", "");
163 | });
164 | 
165 | test("Opens a feedback widget in the bottom right by default", async ({
166 |   page,
167 | }) => {
168 |   const container = await getOpenedWidgetContainer(page);
169 | 
170 |   await expect(container).toBeAttached();
171 | 
172 |   const bbox = await container.locator("dialog").boundingBox();
173 |   expect(bbox?.x).toEqual(WINDOW_WIDTH - bbox!.width - 16);
174 |   expect(bbox?.y).toBeGreaterThan(WINDOW_HEIGHT - bbox!.height - 30); // Account for browser differences
175 |   expect(bbox?.y).toBeLessThan(WINDOW_HEIGHT - bbox!.height);
176 | });
177 | 
178 | test("Opens a feedback widget in the correct position when overridden", async ({
179 |   page,
180 | }) => {
181 |   const container = await getOpenedWidgetContainer(page, {
182 |     feedback: {
183 |       ui: {
184 |         position: {
185 |           type: "DIALOG",
186 |           placement: "top-left",
187 |         },
188 |       },
189 |     },
190 |   });
191 | 
192 |   await expect(container).toBeAttached();
193 | 
194 |   const bbox = await container.locator("dialog").boundingBox();
195 |   expect(bbox?.x).toEqual(16);
196 |   expect(bbox?.y).toBeGreaterThan(0); // Account for browser differences
197 |   expect(bbox?.y).toBeLessThanOrEqual(16);
198 | });
199 | 
200 | test("Opens a feedback widget with the correct translations", async ({
201 |   page,
202 | }) => {
203 |   const translations: Partial<FeedbackTranslations> = {
204 |     ScoreStatusDescription: "Choisissez une note et laissez un commentaire",
205 |     ScoreVeryDissatisfiedLabel: "Très insatisfait",
206 |     ScoreDissatisfiedLabel: "Insatisfait",
207 |     ScoreNeutralLabel: "Neutre",
208 |     ScoreSatisfiedLabel: "Satisfait",
209 |     ScoreVerySatisfiedLabel: "Très satisfait",
210 |     SendButton: "Envoyer",
211 |   };
212 | 
213 |   const container = await getOpenedWidgetContainer(page, {
214 |     feedback: {
215 |       ui: {
216 |         translations,
217 |       },
218 |     },
219 |   });
220 | 
221 |   await expect(container).toBeAttached();
222 |   await expect(container).toContainText(translations.ScoreStatusDescription!);
223 |   await expect(container).toContainText(
224 |     translations.ScoreVeryDissatisfiedLabel!,
225 |   );
226 |   await expect(container).toContainText(translations.ScoreDissatisfiedLabel!);
227 |   await expect(container).toContainText(translations.ScoreNeutralLabel!);
228 |   await expect(container).toContainText(translations.ScoreSatisfiedLabel!);
229 |   await expect(container).toContainText(translations.ScoreVerySatisfiedLabel!);
230 |   await expect(container).toContainText(translations.SendButton!);
231 | });
232 | 
233 | test("Sends a request when choosing a score immediately", async ({ page }) => {
234 |   const expectedScore = pick([1, 2, 3, 4, 5]);
235 |   let sentJSON: object | null = null;
236 | 
237 |   await page.route(`${API_HOST}/feedback`, async (route) => {
238 |     sentJSON = route.request().postDataJSON();
239 |     await route.fulfill({
240 |       status: 200,
241 |       body: JSON.stringify({ feedbackId: "123" }),
242 |       contentType: "application/json",
243 |     });
244 |   });
245 | 
246 |   const container = await getOpenedWidgetContainer(page);
247 |   await setScore(container, expectedScore);
248 | 
249 |   await expect
250 |     .poll(() => sentJSON)
251 |     .toEqual({
252 |       companyId: "bar",
253 |       key: "flag1",
254 |       score: expectedScore,
255 |       question: "baz",
256 |       userId: "foo",
257 |       source: "widget",
258 |     });
259 | });
260 | 
261 | test("Shows a success message after submitting a score", async ({ page }) => {
262 |   await page.route(`${API_HOST}/feedback`, async (route) => {
263 |     await route.fulfill({
264 |       status: 200,
265 |       body: JSON.stringify({ feedbackId: "123" }),
266 |       contentType: "application/json",
267 |     });
268 |   });
269 | 
270 |   const container = await getOpenedWidgetContainer(page);
271 | 
272 |   await expect(
273 |     container.getByText(DEFAULT_TRANSLATIONS.ScoreStatusDescription),
274 |   ).toHaveCSS("opacity", "1");
275 |   await expect(
276 |     container.getByText(DEFAULT_TRANSLATIONS.ScoreStatusReceived),
277 |   ).toHaveCSS("opacity", "0");
278 | 
279 |   await setScore(container, 3);
280 | 
281 |   await expect(
282 |     container.getByText(DEFAULT_TRANSLATIONS.ScoreStatusDescription),
283 |   ).toHaveCSS("opacity", "0");
284 |   await expect(
285 |     container.getByText(DEFAULT_TRANSLATIONS.ScoreStatusReceived),
286 |   ).toHaveCSS("opacity", "1");
287 | });
288 | 
289 | test("Updates the score on every change", async ({ page }) => {
290 |   let lastSentJSON: object | null = null;
291 | 
292 |   await page.route(`${API_HOST}/feedback`, async (route) => {
293 |     lastSentJSON = route.request().postDataJSON();
294 |     await route.fulfill({
295 |       status: 200,
296 |       body: JSON.stringify({ feedbackId: "123" }),
297 |       contentType: "application/json",
298 |     });
299 |   });
300 | 
301 |   const container = await getOpenedWidgetContainer(page);
302 | 
303 |   await setScore(container, 1);
304 |   await setScore(container, 5);
305 |   await setScore(container, 3);
306 | 
307 |   await expect
308 |     .poll(() => lastSentJSON)
309 |     .toEqual({
310 |       feedbackId: "123",
311 |       companyId: "bar",
312 |       key: "flag1",
313 |       question: "baz",
314 |       score: 3,
315 |       userId: "foo",
316 |       source: "widget",
317 |     });
318 | });
319 | 
320 | test("Shows the comment field after submitting a score", async ({ page }) => {
321 |   await page.route(`${API_HOST}/feedback`, async (route) => {
322 |     await route.fulfill({
323 |       status: 200,
324 |       body: JSON.stringify({ feedbackId: "123" }),
325 |       contentType: "application/json",
326 |     });
327 |   });
328 | 
329 |   const container = await getOpenedWidgetContainer(page);
330 | 
331 |   await expect(container.locator(".form-expanded-content")).toHaveCSS(
332 |     "opacity",
333 |     "0",
334 |   );
335 | 
336 |   await setScore(container, 1);
337 | 
338 |   await expect(container.locator(".form-expanded-content")).toHaveCSS(
339 |     "opacity",
340 |     "1",
341 |   );
342 | });
343 | 
344 | test("Sends a request with both the score and comment when submitting", async ({
345 |   page,
346 | }) => {
347 |   const expectedComment = `This is my comment: ${Math.random()}`;
348 |   const expectedScore = pick([1, 2, 3, 4, 5]);
349 | 
350 |   let sentJSON: object | null = null;
351 | 
352 |   await page.route(`${API_HOST}/feedback`, async (route) => {
353 |     sentJSON = route.request().postDataJSON();
354 |     await route.fulfill({
355 |       status: 200,
356 |       body: JSON.stringify({ feedbackId: "123" }),
357 |       contentType: "application/json",
358 |     });
359 |   });
360 | 
361 |   const container = await getOpenedWidgetContainer(page);
362 | 
363 |   await setScore(container, expectedScore);
364 |   await setComment(container, expectedComment);
365 |   await submitForm(container);
366 | 
367 |   expect(sentJSON).toEqual({
368 |     comment: expectedComment,
369 |     score: expectedScore,
370 |     companyId: "bar",
371 |     question: "baz",
372 |     key: "flag1",
373 |     feedbackId: "123",
374 |     userId: "foo",
375 |     source: "widget",
376 |   });
377 | });
378 | 
379 | test("Shows a success message after submitting", async ({ page }) => {
380 |   await page.route(`${API_HOST}/feedback`, async (route) => {
381 |     await route.fulfill({
382 |       status: 200,
383 |       body: JSON.stringify({ feedbackId: "123" }),
384 |       contentType: "application/json",
385 |     });
386 |   });
387 | 
388 |   const container = await getOpenedWidgetContainer(page);
389 | 
390 |   await setScore(container, 3);
391 |   await setComment(container, "Test comment!");
392 |   await submitForm(container);
393 | 
394 |   await expect(
395 |     container.getByText(DEFAULT_TRANSLATIONS.SuccessMessage),
396 |   ).toBeVisible();
397 | });
398 | 
399 | test("Closes the dialog shortly after submitting", async ({ page }) => {
400 |   await page.route(`${API_HOST}/feedback`, async (route) => {
401 |     await route.fulfill({
402 |       status: 200,
403 |       body: JSON.stringify({ feedbackId: "123" }),
404 |       contentType: "application/json",
405 |     });
406 |   });
407 | 
408 |   const container = await getOpenedWidgetContainer(page);
409 | 
410 |   await setScore(container, 3);
411 |   await setComment(container, "Test comment!");
412 |   await submitForm(container);
413 | 
414 |   await expect(container.locator("dialog")).not.toHaveAttribute("open", "");
415 | });
416 | 
417 | test("Blocks event propagation to the containing document", async ({
418 |   page,
419 | }) => {
420 |   const container = await getOpenedWidgetContainer(page);
421 |   const textarea = container.locator('textarea[name="comment"]');
422 | 
423 |   await page.evaluate(
424 |     ({ trackedEvents }) => {
425 |       window.eventsFired = {};
426 | 
427 |       for (const event of trackedEvents) {
428 |         document.addEventListener(event, () => {
429 |           window.eventsFired[event] = true;
430 |         });
431 |       }
432 |     },
433 |     { trackedEvents: propagatedEvents },
434 |   );
435 | 
436 |   await textarea.focus();
437 |   // Fires 'keydown', 'keyup' and 'keypress' events
438 |   await page.keyboard.type("Hello World");
439 | 
440 |   const firedEvents = await page.evaluate(() => {
441 |     return window.eventsFired;
442 |   });
443 | 
444 |   // No events are allowed to fire, object should be empty
445 |   expect(firedEvents).toEqual({});
446 | });
447 | 
```

--------------------------------------------------------------------------------
/packages/browser-sdk/test/sse.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { http, HttpResponse } from "msw";
  2 | import { cleanAll, isDone } from "nock";
  3 | import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
  4 | 
  5 | import {
  6 |   forgetAuthToken,
  7 |   getAuthToken,
  8 |   rememberAuthToken,
  9 | } from "../src/feedback/promptStorage";
 10 | import { HttpClient } from "../src/httpClient";
 11 | import { AblySSEChannel } from "../src/sse";
 12 | 
 13 | import { server } from "./mocks/server";
 14 | import { testLogger } from "./testLogger";
 15 | 
 16 | const KEY = "123";
 17 | const sseHost = "https://ssehost.com/path";
 18 | const tokenRequest = {
 19 |   keyName: "key-name",
 20 |   other: "other",
 21 | };
 22 | const tokenDetails = {
 23 |   token: "token",
 24 |   expires: new Date("2023-01-01T00:00:00.000Z").getTime(),
 25 | };
 26 | 
 27 | const userId = "foo";
 28 | const channel = "channel";
 29 | 
 30 | function createSSEChannel(callback: (message: any) => void = vi.fn()) {
 31 |   const httpClient = new HttpClient(KEY);
 32 |   return new AblySSEChannel(
 33 |     userId,
 34 |     channel,
 35 |     sseHost,
 36 |     callback,
 37 |     httpClient,
 38 |     testLogger,
 39 |   );
 40 | }
 41 | 
 42 | Object.defineProperty(window, "EventSource", {
 43 |   value: vi.fn().mockImplementation(() => {
 44 |     // ignore
 45 |   }),
 46 | });
 47 | 
 48 | vi.mock("../src/feedback/promptStorage", () => {
 49 |   return {
 50 |     rememberAuthToken: vi.fn(),
 51 |     forgetAuthToken: vi.fn(),
 52 |     getAuthToken: vi.fn(),
 53 |   };
 54 | });
 55 | 
 56 | function setupAuthNock(success: boolean | number) {
 57 |   server.use(
 58 |     http.get("https://front.reflag.com/feedback/prompting-auth", async () => {
 59 |       if (success === true) {
 60 |         return HttpResponse.json({ success: true, ...tokenRequest });
 61 |       } else if (success === false) {
 62 |         return HttpResponse.json({ success: false });
 63 |       } else {
 64 |         return new HttpResponse(null, {
 65 |           status: success,
 66 |         });
 67 |       }
 68 |     }),
 69 |   );
 70 | }
 71 | 
 72 | function setupTokenNock(success: boolean) {
 73 |   server.use(
 74 |     http.post(
 75 |       `${sseHost}/keys/${tokenRequest.keyName}/requestToken`,
 76 |       async () => {
 77 |         if (success) {
 78 |           return HttpResponse.json(tokenDetails);
 79 |         } else {
 80 |           return new HttpResponse(null, {
 81 |             status: 401,
 82 |           });
 83 |         }
 84 |       },
 85 |     ),
 86 |   );
 87 | }
 88 | 
 89 | describe("connection handling", () => {
 90 |   afterEach(() => {
 91 |     vi.clearAllMocks();
 92 |     vi.mocked(getAuthToken).mockReturnValue(undefined);
 93 |   });
 94 | 
 95 |   test("appends /sse to the sseHost", async () => {
 96 |     const sse = createSSEChannel();
 97 |     const addEventListener = vi.fn();
 98 | 
 99 |     vi.mocked(window.EventSource).mockReturnValue({
100 |       addEventListener,
101 |     } as any);
102 | 
103 |     setupAuthNock(true);
104 |     setupTokenNock(true);
105 | 
106 |     await sse.connect();
107 | 
108 |     const lastCall = vi.mocked(window.EventSource).mock.calls[0][0];
109 | 
110 |     expect(lastCall.toString()).toMatch(`${sseHost}/sse`);
111 |   });
112 | 
113 |   test("rejects if auth endpoint is not success", async () => {
114 |     const sse = createSSEChannel();
115 | 
116 |     setupAuthNock(false);
117 |     const res = await sse.connect();
118 |     expect(res).toBeUndefined();
119 | 
120 |     expect(vi.mocked(window.EventSource)).not.toHaveBeenCalled();
121 |   });
122 | 
123 |   test("rejects if auth endpoint is not 200", async () => {
124 |     const sse = createSSEChannel();
125 | 
126 |     setupAuthNock(403);
127 | 
128 |     const res = await sse.connect();
129 |     expect(res).toBeUndefined();
130 | 
131 |     expect(vi.mocked(window.EventSource)).not.toHaveBeenCalled();
132 |   });
133 | 
134 |   test("rejects if token endpoint rejects", async () => {
135 |     const sse = createSSEChannel();
136 | 
137 |     setupAuthNock(true);
138 |     setupTokenNock(false);
139 | 
140 |     const res = await sse.connect();
141 |     expect(res).toBeUndefined();
142 | 
143 |     expect(vi.mocked(window.EventSource)).not.toHaveBeenCalled();
144 |   });
145 | 
146 |   test("obtains token, connects and subscribes, then closes", async () => {
147 |     const addEventListener = vi.fn();
148 |     const close = vi.fn();
149 | 
150 |     vi.mocked(window.EventSource).mockReturnValue({
151 |       addEventListener,
152 |       close,
153 |     } as any);
154 | 
155 |     const sse = createSSEChannel();
156 | 
157 |     setupAuthNock(true);
158 |     setupTokenNock(true);
159 | 
160 |     await sse.connect();
161 | 
162 |     expect(getAuthToken).toHaveBeenCalledWith(userId);
163 |     expect(rememberAuthToken).toHaveBeenCalledWith(
164 |       userId,
165 |       channel,
166 |       "token",
167 |       new Date("2023-01-01T00:00:00.000Z"),
168 |     );
169 |     expect(vi.mocked(window.EventSource)).toHaveBeenCalledTimes(1);
170 |     expect(addEventListener).toHaveBeenCalledTimes(3);
171 |     expect(addEventListener).toHaveBeenCalledWith(
172 |       "error",
173 |       expect.any(Function),
174 |     );
175 |     expect(addEventListener).toHaveBeenCalledWith(
176 |       "message",
177 |       expect.any(Function),
178 |     );
179 |     expect(addEventListener).toHaveBeenCalledWith("open", expect.any(Function));
180 | 
181 |     expect(sse.isConnected()).toBe(true);
182 | 
183 |     sse.disconnect();
184 | 
185 |     expect(close).toHaveBeenCalledTimes(1);
186 |     expect(sse.isConnected()).toBe(false);
187 |   });
188 | 
189 |   test("reuses cached token", async () => {
190 |     const sse = createSSEChannel();
191 |     vi.mocked(getAuthToken).mockReturnValue({
192 |       channel: channel,
193 |       token: "cached_token",
194 |     });
195 | 
196 |     const addEventListener = vi.fn();
197 |     const close = vi.fn();
198 | 
199 |     vi.mocked(window.EventSource).mockReturnValue({
200 |       addEventListener,
201 |       close,
202 |     } as any);
203 | 
204 |     await sse.connect();
205 | 
206 |     expect(getAuthToken).toHaveBeenCalledWith(userId);
207 |     expect(rememberAuthToken).not.toHaveBeenCalled();
208 | 
209 |     expect(sse.isConnected()).toBe(true);
210 |   });
211 | 
212 |   test("does not reuse cached token with wrong channel", async () => {
213 |     const sse = createSSEChannel();
214 | 
215 |     vi.mocked(getAuthToken).mockReturnValue({
216 |       channel: "haha",
217 |       token: "cached_token",
218 |     });
219 | 
220 |     const addEventListener = vi.fn();
221 |     const close = vi.fn();
222 | 
223 |     vi.mocked(window.EventSource).mockReturnValue({
224 |       addEventListener,
225 |       close,
226 |     } as any);
227 | 
228 |     setupAuthNock(true);
229 |     setupTokenNock(true);
230 | 
231 |     await sse.connect();
232 | 
233 |     expect(rememberAuthToken).toHaveBeenCalledWith(
234 |       userId,
235 |       channel,
236 |       "token",
237 |       new Date("2023-01-01T00:00:00.000Z"),
238 |     );
239 |   });
240 | 
241 |   test("does not try to re-connect if already connecting", async () => {
242 |     const sse = createSSEChannel();
243 | 
244 |     const close = vi.fn();
245 |     vi.mocked(window.EventSource).mockReturnValue({
246 |       addEventListener: vi.fn(),
247 |       close,
248 |     } as any);
249 | 
250 |     setupAuthNock(true);
251 |     setupTokenNock(true);
252 | 
253 |     const c1 = sse.connect();
254 |     const c2 = sse.connect();
255 | 
256 |     await c1;
257 |     await c2;
258 | 
259 |     expect(close).toHaveBeenCalledTimes(0);
260 |     expect(vi.mocked(window.EventSource)).toHaveBeenCalledTimes(1);
261 |   });
262 | 
263 |   test("does not re-connect if already connected", async () => {
264 |     const sse = createSSEChannel();
265 | 
266 |     const close = vi.fn();
267 |     vi.mocked(window.EventSource).mockReturnValue({
268 |       addEventListener: vi.fn(),
269 |       close,
270 |     } as any);
271 | 
272 |     setupAuthNock(true);
273 |     setupTokenNock(true);
274 | 
275 |     await sse.connect();
276 |     await sse.connect();
277 | 
278 |     expect(close).toHaveBeenCalledTimes(0);
279 |     expect(vi.mocked(window.EventSource)).toHaveBeenCalledTimes(1);
280 |   });
281 | 
282 |   test("disconnects only if connected", async () => {
283 |     const sse = createSSEChannel();
284 | 
285 |     const close = vi.fn();
286 |     vi.mocked(window.EventSource).mockReturnValue({
287 |       close,
288 |     } as any);
289 | 
290 |     sse.disconnect();
291 | 
292 |     expect(close).not.toHaveBeenCalled();
293 |   });
294 | });
295 | 
296 | describe("message handling", () => {
297 |   beforeEach(() => {
298 |     setupAuthNock(true);
299 |     setupTokenNock(true);
300 |   });
301 | 
302 |   afterEach(() => {
303 |     expect(isDone()).toBe(true);
304 | 
305 |     vi.clearAllMocks();
306 |     cleanAll();
307 |   });
308 | 
309 |   test("passes message to callback", async () => {
310 |     const callback = vi.fn();
311 |     const sse = createSSEChannel(callback);
312 | 
313 |     let messageCallback: ((e: Event) => void) | undefined = undefined;
314 |     const addEventListener = (event: string, cb: (e: Event) => void) => {
315 |       if (event === "message") {
316 |         messageCallback = cb;
317 |       }
318 |     };
319 | 
320 |     vi.mocked(window.EventSource).mockReturnValue({
321 |       addEventListener,
322 |     } as any);
323 | 
324 |     await sse.connect();
325 | 
326 |     expect(messageCallback).toBeDefined();
327 | 
328 |     messageCallback!({
329 |       data: JSON.stringify({ data: JSON.stringify(userId) }),
330 |     } as any);
331 |     expect(callback).toHaveBeenCalledWith(userId);
332 | 
333 |     messageCallback!({
334 |       data: null,
335 |     } as any);
336 | 
337 |     messageCallback!({
338 |       data: JSON.stringify({}),
339 |     } as any);
340 | 
341 |     expect(callback).toHaveBeenCalledTimes(1);
342 |   });
343 | 
344 |   test("disconnects on unknown event source errors without data", async () => {
345 |     const sse = createSSEChannel();
346 | 
347 |     let errorCallback: ((e: Event) => Promise<void>) | undefined = undefined;
348 |     const addEventListener = (event: string, cb: (e: Event) => void) => {
349 |       if (event === "error") {
350 |         errorCallback = cb as typeof errorCallback;
351 |       }
352 |     };
353 | 
354 |     const close = vi.fn();
355 |     vi.mocked(window.EventSource).mockReturnValue({
356 |       addEventListener,
357 |       close,
358 |     } as any);
359 | 
360 |     await sse.connect();
361 | 
362 |     expect(errorCallback).toBeDefined();
363 | 
364 |     await errorCallback!({} as any);
365 | 
366 |     expect(forgetAuthToken).not.toHaveBeenCalled();
367 |     expect(close).toHaveBeenCalledTimes(1);
368 |   });
369 | 
370 |   test("disconnects on unknown event source errors with data", async () => {
371 |     const sse = createSSEChannel();
372 |     let errorCallback: ((e: Event) => Promise<void>) | undefined = undefined;
373 |     const addEventListener = (event: string, cb: (e: Event) => void) => {
374 |       if (event === "error") {
375 |         errorCallback = cb as typeof errorCallback;
376 |       }
377 |     };
378 | 
379 |     const close = vi.fn();
380 |     vi.mocked(window.EventSource).mockReturnValue({
381 |       addEventListener,
382 |       close,
383 |     } as any);
384 | 
385 |     await sse.connect();
386 | 
387 |     expect(errorCallback).toBeDefined();
388 | 
389 |     await errorCallback!(
390 |       new MessageEvent("error", {
391 |         data: JSON.stringify({ code: 400 }),
392 |       }),
393 |     );
394 | 
395 |     expect(close).toHaveBeenCalledTimes(1);
396 |   });
397 | 
398 |   test("disconnects when ably reports token errors", async () => {
399 |     const sse = createSSEChannel();
400 | 
401 |     let errorCallback: ((e: Event) => Promise<void>) | undefined = undefined;
402 |     const addEventListener = (event: string, cb: (e: Event) => void) => {
403 |       if (event === "error") {
404 |         errorCallback = cb as typeof errorCallback;
405 |       }
406 |     };
407 | 
408 |     const close = vi.fn();
409 |     vi.mocked(window.EventSource).mockReturnValue({
410 |       addEventListener,
411 |       close,
412 |     } as any);
413 | 
414 |     await sse.connect();
415 | 
416 |     await errorCallback!(
417 |       new MessageEvent("error", {
418 |         data: JSON.stringify({ code: 40110 }),
419 |       }),
420 |     );
421 | 
422 |     expect(forgetAuthToken).toHaveBeenCalledTimes(1);
423 |     expect(close).toHaveBeenCalled();
424 |   });
425 | });
426 | 
427 | describe("automatic retries", () => {
428 |   // const nockWait = (n: nock.Scope) => {
429 |   //   return new Promise((resolve) => {
430 |   //     n.on("replied", () => {
431 |   //       resolve(undefined);
432 |   //     });
433 |   //   });
434 |   // };
435 | 
436 |   beforeEach(() => {
437 |     vi.clearAllMocks();
438 |     cleanAll();
439 |   });
440 | 
441 |   afterEach(() => {
442 |     expect(isDone()).toBe(true);
443 |   });
444 | 
445 |   test("opens and connects to a channel", async () => {
446 |     const sse = createSSEChannel();
447 | 
448 |     setupAuthNock(true);
449 |     setupTokenNock(true);
450 | 
451 |     sse.open();
452 | 
453 |     await vi.waitFor(() =>
454 |       sse.isConnected() ? Promise.resolve() : Promise.reject(),
455 |     );
456 | 
457 |     expect(sse.isConnected()).toBe(true);
458 |   });
459 | 
460 |   test("opens and connects later to a failed channel", async () => {
461 |     const sse = createSSEChannel();
462 | 
463 |     setupAuthNock(false);
464 | 
465 |     sse.open({ retryInterval: 10 });
466 | 
467 |     await vi.waitUntil(() => !sse.isConnected());
468 |     setupAuthNock(true);
469 |     setupTokenNock(true);
470 | 
471 |     await vi.waitUntil(() => sse.isConnected());
472 | 
473 |     expect(sse.isConnected()).toBe(true);
474 |     expect(sse.isActive()).toBe(true);
475 |   });
476 | 
477 |   test("resets retry count on successful connect", async () => {
478 |     const sse = createSSEChannel();
479 | 
480 |     // mock event source
481 |     let errorCallback: ((e: Event) => Promise<void>) | undefined = undefined;
482 |     const addEventListener = (event: string, cb: (e: Event) => void) => {
483 |       if (event === "error") {
484 |         errorCallback = cb as typeof errorCallback;
485 |       }
486 |     };
487 | 
488 |     const close = vi.fn();
489 |     vi.mocked(window.EventSource).mockReturnValue({
490 |       addEventListener,
491 |       close,
492 |     } as any);
493 | 
494 |     // make initial failed attempt
495 |     setupAuthNock(false);
496 | 
497 |     sse.open({ retryInterval: 100, retryCount: 1 });
498 | 
499 |     const attempt = async () => {
500 |       setupAuthNock(true);
501 |       setupTokenNock(true);
502 | 
503 |       await vi.waitUntil(() => sse.isConnected());
504 | 
505 |       expect(sse.isConnected()).toBe(true);
506 | 
507 |       // simulate an error
508 |       await errorCallback!({} as any);
509 | 
510 |       expect(sse.isConnected()).toBe(false);
511 |     };
512 | 
513 |     await attempt();
514 |     await attempt();
515 |     await attempt();
516 |   });
517 | 
518 |   test("reconnects if manually disconnected", async () => {
519 |     const sse = createSSEChannel();
520 | 
521 |     vi.mocked(window.EventSource).mockReturnValue({
522 |       addEventListener: vi.fn(),
523 |       close: vi.fn(),
524 |     } as any);
525 | 
526 |     setupAuthNock(true);
527 |     setupTokenNock(true);
528 | 
529 |     vi.useFakeTimers();
530 |     await sse.open({ retryInterval: 100 });
531 | 
532 |     sse.disconnect();
533 | 
534 |     setupAuthNock(true);
535 |     setupTokenNock(true);
536 | 
537 |     vi.advanceTimersByTime(100);
538 | 
539 |     vi.useRealTimers();
540 | 
541 |     await vi.waitUntil(() => sse.isConnected());
542 | 
543 |     expect(sse.isConnected()).toBe(true);
544 |     expect(sse.isActive()).toBe(true);
545 |   });
546 | 
547 |   test("opens and does not connect later to a failed channel if no retries", async () => {
548 |     const sse = createSSEChannel();
549 | 
550 |     setupAuthNock(false);
551 | 
552 |     vi.useFakeTimers();
553 |     sse.open({
554 |       retryCount: 0,
555 |       retryInterval: 100,
556 |     });
557 | 
558 |     vi.advanceTimersByTime(100);
559 |     vi.useRealTimers();
560 | 
561 |     await vi.waitUntil(() => !sse.isActive());
562 | 
563 |     expect(sse.isActive()).toBe(false);
564 |   });
565 | 
566 |   test("closes an open channel", async () => {
567 |     const sse = createSSEChannel();
568 | 
569 |     setupAuthNock(true);
570 |     setupTokenNock(true);
571 | 
572 |     const close = vi.fn();
573 |     vi.mocked(window.EventSource).mockReturnValue({
574 |       addEventListener: vi.fn(),
575 |       close,
576 |     } as any);
577 | 
578 |     sse.open();
579 | 
580 |     await vi.waitUntil(() => sse.isConnected());
581 | 
582 |     sse.close();
583 | 
584 |     expect(sse.isConnected()).toBe(false);
585 |     expect(close).toHaveBeenCalledTimes(1);
586 |     expect(sse.isActive()).toBe(false);
587 |   });
588 | });
589 | 
```

--------------------------------------------------------------------------------
/packages/browser-sdk/src/flag/flags.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { deepEqual } from "fast-equals";
  2 | 
  3 | import { FLAG_EVENTS_PER_MIN, FLAGS_EXPIRE_MS, IS_SERVER } from "../config";
  4 | import { ReflagContext } from "../context";
  5 | import { HttpClient } from "../httpClient";
  6 | import { Logger, loggerWithPrefix } from "../logger";
  7 | import RateLimiter from "../rateLimiter";
  8 | 
  9 | import { FlagCache, isObject, parseAPIFlagsResponse } from "./flagCache";
 10 | 
 11 | /**
 12 |  * A flag fetched from the server.
 13 |  */
 14 | export type RawFlag = {
 15 |   /**
 16 |    * Flag key.
 17 |    */
 18 |   key: string;
 19 | 
 20 |   /**
 21 |    * Result of flag evaluation.
 22 |    * Note: does not take local overrides into account.
 23 |    */
 24 |   isEnabled: boolean;
 25 | 
 26 |   /**
 27 |    * If not null or undefined, the result is being overridden locally
 28 |    */
 29 |   isEnabledOverride?: boolean | null;
 30 | 
 31 |   /**
 32 |    * Version of targeting rules.
 33 |    */
 34 |   targetingVersion?: number;
 35 | 
 36 |   /**
 37 |    * Rule evaluation results.
 38 |    */
 39 |   ruleEvaluationResults?: boolean[];
 40 | 
 41 |   /**
 42 |    * Missing context fields.
 43 |    */
 44 |   missingContextFields?: string[];
 45 | 
 46 |   /**
 47 |    * Optional user-defined dynamic configuration.
 48 |    */
 49 |   config?: {
 50 |     /**
 51 |      * The key of the matched configuration value.
 52 |      */
 53 |     key: string;
 54 | 
 55 |     /**
 56 |      * The version of the matched configuration value.
 57 |      */
 58 |     version?: number;
 59 | 
 60 |     /**
 61 |      * The optional user-supplied payload data.
 62 |      */
 63 |     payload?: any;
 64 | 
 65 |     /**
 66 |      * The rule evaluation results.
 67 |      */
 68 |     ruleEvaluationResults?: boolean[];
 69 | 
 70 |     /**
 71 |      * The missing context fields.
 72 |      */
 73 |     missingContextFields?: string[];
 74 |   };
 75 | };
 76 | 
 77 | export type RawFlags = Record<string, RawFlag>;
 78 | 
 79 | export type FallbackFlagOverride =
 80 |   | {
 81 |       key: string;
 82 |       payload: any;
 83 |     }
 84 |   | true;
 85 | 
 86 | type FallbackFlags = Record<string, FallbackFlagOverride>;
 87 | 
 88 | type Config = {
 89 |   timeoutMs: number;
 90 |   staleTimeMs: number;
 91 |   staleWhileRevalidate: boolean;
 92 |   expireTimeMs: number;
 93 |   offline: boolean;
 94 | };
 95 | 
 96 | export const DEFAULT_FLAGS_CONFIG: Config = {
 97 |   timeoutMs: 5000,
 98 |   staleTimeMs: 0,
 99 |   staleWhileRevalidate: false,
100 |   expireTimeMs: FLAGS_EXPIRE_MS,
101 |   offline: false,
102 | };
103 | 
104 | export function validateFlagsResponse(response: any) {
105 |   if (!isObject(response)) {
106 |     return;
107 |   }
108 | 
109 |   if (typeof response.success !== "boolean" || !isObject(response.features)) {
110 |     return;
111 |   }
112 | 
113 |   const flags = parseAPIFlagsResponse(response.features);
114 | 
115 |   if (!flags) {
116 |     return;
117 |   }
118 | 
119 |   return {
120 |     success: response.success,
121 |     flags,
122 |   };
123 | }
124 | 
125 | export function flattenJSON(obj: Record<string, any>): Record<string, any> {
126 |   const result: Record<string, any> = {};
127 |   for (const key in obj) {
128 |     if (typeof obj[key] === "object") {
129 |       const flat = flattenJSON(obj[key]);
130 |       for (const flatKey in flat) {
131 |         result[`${key}.${flatKey}`] = flat[flatKey];
132 |       }
133 |     } else if (typeof obj[key] !== "undefined") {
134 |       result[key] = obj[key];
135 |     }
136 |   }
137 |   return result;
138 | }
139 | 
140 | /**
141 |  * Event representing checking the flag evaluation result
142 |  */
143 | export interface CheckEvent {
144 |   /**
145 |    * `check-is-enabled` means `isEnabled` was checked, `check-config` means `config` was checked.
146 |    */
147 |   action: "check-is-enabled" | "check-config";
148 | 
149 |   /**
150 |    * Flag key.
151 |    */
152 |   key: string;
153 | 
154 |   /**
155 |    * Result of flag or configuration evaluation.
156 |    * If `action` is `check-is-enabled`, this is the result of the flag evaluation and `value` is a boolean.
157 |    * If `action` is `check-config`, this is the result of the configuration evaluation.
158 |    */
159 |   value?: boolean | { key: string; payload: any };
160 | 
161 |   /**
162 |    * Version of targeting rules.
163 |    */
164 |   version?: number;
165 | 
166 |   /**
167 |    * Rule evaluation results.
168 |    */
169 |   ruleEvaluationResults?: boolean[];
170 | 
171 |   /**
172 |    * Missing context fields.
173 |    */
174 |   missingContextFields?: string[];
175 | }
176 | 
177 | const localStorageFetchedFlagsKey = `__reflag_fetched_flags`;
178 | const storageOverridesKey = `__reflag_overrides`;
179 | 
180 | export type FlagOverrides = Record<string, boolean | undefined>;
181 | 
182 | type FlagsClientOptions = Partial<Config> & {
183 |   bootstrappedFlags?: RawFlags;
184 |   fallbackFlags?: Record<string, FallbackFlagOverride> | string[];
185 |   cache?: FlagCache;
186 |   rateLimiter?: RateLimiter;
187 | };
188 | 
189 | /**
190 |  * @internal
191 |  */
192 | export class FlagsClient {
193 |   private initialized = false;
194 |   private bootstrapped = false;
195 | 
196 |   private rateLimiter: RateLimiter;
197 |   private readonly logger: Logger;
198 | 
199 |   private cache: FlagCache;
200 |   private fetchedFlags: RawFlags = {};
201 |   private flagOverrides: FlagOverrides = {};
202 |   private flags: RawFlags = {};
203 |   private fallbackFlags: FallbackFlags = {};
204 | 
205 |   private config: Config = DEFAULT_FLAGS_CONFIG;
206 | 
207 |   private eventTarget = new EventTarget();
208 |   private abortController: AbortController = new AbortController();
209 | 
210 |   constructor(
211 |     private httpClient: HttpClient,
212 |     private context: ReflagContext,
213 |     logger: Logger,
214 |     {
215 |       bootstrappedFlags,
216 |       cache,
217 |       rateLimiter,
218 |       fallbackFlags,
219 |       ...config
220 |     }: FlagsClientOptions = {},
221 |   ) {
222 |     this.config = {
223 |       ...this.config,
224 |       ...config,
225 |     };
226 | 
227 |     this.logger = loggerWithPrefix(logger, "[Flags]");
228 |     this.rateLimiter =
229 |       rateLimiter ?? new RateLimiter(FLAG_EVENTS_PER_MIN, this.logger);
230 |     this.cache =
231 |       cache ??
232 |       this.setupCache(this.config.staleTimeMs, this.config.expireTimeMs);
233 |     this.fallbackFlags = this.setupFallbackFlags(fallbackFlags);
234 | 
235 |     if (bootstrappedFlags) {
236 |       this.bootstrapped = true;
237 |       this.setFetchedFlags(bootstrappedFlags, false);
238 |     }
239 | 
240 |     this.flagOverrides = this.getOverridesCache();
241 |   }
242 | 
243 |   async initialize() {
244 |     if (this.initialized) {
245 |       this.logger.warn("flags client already initialized");
246 |       return;
247 |     }
248 |     this.initialized = true;
249 | 
250 |     if (!this.bootstrapped) {
251 |       this.setFetchedFlags((await this.maybeFetchFlags()) || {});
252 |     }
253 | 
254 |     // Apply overrides and trigger update if flags have changed
255 |     this.updateFlags();
256 |   }
257 | 
258 |   /**
259 |    * Stop the client.
260 |    */
261 |   public stop() {
262 |     this.abortController.abort();
263 |   }
264 | 
265 |   getFlags(): RawFlags {
266 |     return this.flags;
267 |   }
268 | 
269 |   getFetchedFlags(): RawFlags {
270 |     return this.fetchedFlags;
271 |   }
272 | 
273 |   setFetchedFlags(fetchedFlags: RawFlags, triggerEvent = true) {
274 |     // Create a new fetched flags object making sure to clone the flags
275 |     this.fetchedFlags = { ...fetchedFlags };
276 |     this.warnMissingFlagContextFields(fetchedFlags);
277 |     this.updateFlags(triggerEvent);
278 |   }
279 | 
280 |   async setContext(context: ReflagContext) {
281 |     this.context = context;
282 |     this.setFetchedFlags((await this.maybeFetchFlags()) || {});
283 |   }
284 | 
285 |   updateFlags(triggerEvent = true) {
286 |     const updatedFlags = this.mergeFlags(this.fetchedFlags, this.flagOverrides);
287 |     // Nothing has changed, skipping update
288 |     if (deepEqual(this.flags, updatedFlags)) return;
289 |     this.flags = updatedFlags;
290 |     if (triggerEvent) this.triggerFlagsUpdated();
291 |   }
292 | 
293 |   setFlagOverride(key: string, isEnabled: boolean | null) {
294 |     if (!(typeof isEnabled === "boolean" || isEnabled === null)) {
295 |       throw new Error("setFlagOverride: isEnabled must be boolean or null");
296 |     }
297 | 
298 |     if (isEnabled === null) {
299 |       delete this.flagOverrides[key];
300 |     } else {
301 |       this.flagOverrides[key] = isEnabled;
302 |     }
303 |     this.setOverridesCache(this.flagOverrides);
304 | 
305 |     this.updateFlags();
306 |   }
307 | 
308 |   getFlagOverride(key: string): boolean | null {
309 |     return this.flagOverrides[key] ?? null;
310 |   }
311 | 
312 |   /**
313 |    * Register a callback to be called when the flags are updated.
314 |    * Flags are not guaranteed to have actually changed when the callback is called.
315 |    *
316 |    * @param callback this will be called when the flags are updated.
317 |    * @returns a function that can be called to remove the listener
318 |    */
319 |   onUpdated(callback: () => void) {
320 |     this.eventTarget.addEventListener("flagsUpdated", callback, {
321 |       signal: this.abortController.signal,
322 |     });
323 |   }
324 | 
325 |   /**
326 |    * Send a flag "check" event.
327 |    *
328 |    *
329 |    * @param checkEvent - The flag to send the event for.
330 |    * @param cb - Callback to call after the event is sent. Might be skipped if the event was rate limited.
331 |    */
332 |   async sendCheckEvent(checkEvent: CheckEvent, cb: () => void) {
333 |     if (this.config.offline) {
334 |       return;
335 |     }
336 | 
337 |     const rateLimitKey = `check-event:${this.fetchParams().toString()}:${checkEvent.key}:${checkEvent.version}:${checkEvent.value}`;
338 |     await this.rateLimiter.rateLimited(rateLimitKey, async () => {
339 |       const payload = {
340 |         action: checkEvent.action,
341 |         key: checkEvent.key,
342 |         targetingVersion: checkEvent.version,
343 |         evalContext: this.context,
344 |         evalResult: checkEvent.value,
345 |         evalRuleResults: checkEvent.ruleEvaluationResults,
346 |         evalMissingFields: checkEvent.missingContextFields,
347 |       };
348 | 
349 |       this.httpClient
350 |         .post({
351 |           path: "features/events",
352 |           body: payload,
353 |         })
354 |         .catch((e: any) => {
355 |           this.logger.warn(`failed to send flag check event`, e);
356 |         });
357 | 
358 |       this.logger.debug(`sent flag event`, payload);
359 |       cb();
360 |     });
361 | 
362 |     return checkEvent.value;
363 |   }
364 | 
365 |   async fetchFlags(): Promise<RawFlags | undefined> {
366 |     const params = this.fetchParams();
367 |     try {
368 |       const res = await this.httpClient.get({
369 |         path: "/features/evaluated",
370 |         timeoutMs: this.config.timeoutMs,
371 |         params,
372 |       });
373 | 
374 |       if (!res.ok) {
375 |         let errorBody = null;
376 |         try {
377 |           errorBody = await res.json();
378 |         } catch {
379 |           // ignore
380 |         }
381 | 
382 |         throw new Error(
383 |           "unexpected response code: " +
384 |             res.status +
385 |             " - " +
386 |             JSON.stringify(errorBody),
387 |         );
388 |       }
389 | 
390 |       const typeRes = validateFlagsResponse(await res.json());
391 |       if (!typeRes || !typeRes.success) {
392 |         throw new Error("unable to validate response");
393 |       }
394 | 
395 |       return typeRes.flags;
396 |     } catch (e) {
397 |       this.logger.error("error fetching flags: ", e);
398 |       return;
399 |     }
400 |   }
401 | 
402 |   private setOverridesCache(overrides: FlagOverrides) {
403 |     if (IS_SERVER) return;
404 |     try {
405 |       localStorage.setItem(storageOverridesKey, JSON.stringify(overrides));
406 |     } catch (error) {
407 |       this.logger.warn(
408 |         "storing flag overrides in localStorage failed, overrides won't persist",
409 |         error,
410 |       );
411 |     }
412 |   }
413 | 
414 |   private getOverridesCache(): FlagOverrides {
415 |     if (IS_SERVER) return {};
416 |     try {
417 |       const overridesStored = localStorage.getItem(storageOverridesKey);
418 |       const overrides = JSON.parse(overridesStored || "{}");
419 |       if (!isObject(overrides)) throw new Error("invalid overrides");
420 |       return overrides;
421 |     } catch (error) {
422 |       this.logger.warn(
423 |         "getting flag overrides from localStorage failed",
424 |         error,
425 |       );
426 |       return {};
427 |     }
428 |   }
429 | 
430 |   private async maybeFetchFlags(): Promise<RawFlags | undefined> {
431 |     if (this.config.offline) {
432 |       return;
433 |     }
434 | 
435 |     const cacheKey = this.fetchParams().toString();
436 |     const cachedItem = this.cache.get(cacheKey);
437 | 
438 |     if (cachedItem) {
439 |       if (!cachedItem.stale) return cachedItem.flags;
440 | 
441 |       // serve successful stale cache if `staleWhileRevalidate` is enabled
442 |       if (this.config.staleWhileRevalidate) {
443 |         // re-fetch in the background, but immediately return last successful value
444 |         this.fetchFlags()
445 |           .then((flags) => {
446 |             if (!flags) return;
447 | 
448 |             this.cache.set(cacheKey, {
449 |               flags,
450 |             });
451 |             this.setFetchedFlags(flags);
452 |           })
453 |           .catch(() => {
454 |             // we don't care about the result, we just want to re-fetch
455 |           });
456 |         return cachedItem.flags;
457 |       }
458 |     }
459 | 
460 |     // if there's no cached item or there is a stale one but `staleWhileRevalidate` is disabled
461 |     // try fetching a new one
462 |     const fetchedFlags = await this.fetchFlags();
463 | 
464 |     if (fetchedFlags) {
465 |       this.cache.set(cacheKey, {
466 |         flags: fetchedFlags,
467 |       });
468 |       return fetchedFlags;
469 |     }
470 | 
471 |     if (cachedItem) {
472 |       // fetch failed, return stale cache
473 |       return cachedItem.flags;
474 |     }
475 | 
476 |     // fetch failed, nothing cached => return fallbacks
477 |     return Object.entries(this.fallbackFlags).reduce((acc, [key, override]) => {
478 |       acc[key] = {
479 |         key,
480 |         isEnabled: !!override,
481 |         config:
482 |           typeof override === "object" && "key" in override
483 |             ? {
484 |                 key: override.key,
485 |                 payload: override.payload,
486 |               }
487 |             : undefined,
488 |       };
489 |       return acc;
490 |     }, {} as RawFlags);
491 |   }
492 | 
493 |   private mergeFlags(fetchedFlags: RawFlags, overrides: FlagOverrides) {
494 |     const mergedFlags: RawFlags = {};
495 |     // merge fetched flags with overrides into `this.flags`
496 |     for (const key in fetchedFlags) {
497 |       const fetchedFlag = fetchedFlags[key];
498 |       if (!fetchedFlag) continue;
499 |       const isEnabledOverride = overrides[key] ?? null;
500 |       mergedFlags[key] = { ...fetchedFlag, isEnabledOverride };
501 |     }
502 |     return mergedFlags;
503 |   }
504 | 
505 |   private triggerFlagsUpdated() {
506 |     this.eventTarget.dispatchEvent(new Event("flagsUpdated"));
507 |   }
508 | 
509 |   private setupCache(staleTimeMs = 0, expireTimeMs = FLAGS_EXPIRE_MS) {
510 |     return new FlagCache({
511 |       storage: !IS_SERVER
512 |         ? {
513 |             get: () => localStorage.getItem(localStorageFetchedFlagsKey),
514 |             set: (value) =>
515 |               localStorage.setItem(localStorageFetchedFlagsKey, value),
516 |           }
517 |         : {
518 |             get: () => null,
519 |             set: () => void 0,
520 |           },
521 |       staleTimeMs,
522 |       expireTimeMs,
523 |     });
524 |   }
525 | 
526 |   private setupFallbackFlags(
527 |     fallbackFlags?: Record<string, FallbackFlagOverride> | string[],
528 |   ) {
529 |     if (Array.isArray(fallbackFlags)) {
530 |       return fallbackFlags.reduce(
531 |         (acc, key) => {
532 |           acc[key] = true;
533 |           return acc;
534 |         },
535 |         {} as Record<string, FallbackFlagOverride>,
536 |       );
537 |     } else {
538 |       return fallbackFlags ?? {};
539 |     }
540 |   }
541 | 
542 |   private fetchParams() {
543 |     const flattenedContext = flattenJSON({ context: this.context });
544 |     const params = new URLSearchParams(flattenedContext);
545 |     // publishableKey should be part of the cache key
546 |     params.append("publishableKey", this.httpClient.publishableKey);
547 | 
548 |     // sort the params to ensure that the URL is the same for the same context
549 |     params.sort();
550 | 
551 |     return params;
552 |   }
553 | 
554 |   private warnMissingFlagContextFields(flags: RawFlags) {
555 |     const report: Record<string, string[]> = {};
556 |     for (const flagKey in flags) {
557 |       const flag = flags[flagKey];
558 |       if (flag?.missingContextFields?.length) {
559 |         report[flag.key] = flag.missingContextFields;
560 |       }
561 | 
562 |       if (flag?.config?.missingContextFields?.length) {
563 |         report[`${flag.key}.config`] = flag.config.missingContextFields;
564 |       }
565 |     }
566 | 
567 |     if (Object.keys(report).length > 0) {
568 |       this.rateLimiter.rateLimited(
569 |         `flag-missing-context-fields:${this.fetchParams().toString()}`,
570 |         () => {
571 |           this.logger.warn(
572 |             `flag targeting rules might not be correctly evaluated due to missing context fields.`,
573 |             report,
574 |           );
575 |         },
576 |       );
577 |     }
578 |   }
579 | }
580 | 
```

--------------------------------------------------------------------------------
/packages/browser-sdk/test/flags.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { afterAll, beforeEach, describe, expect, test, vi } from "vitest";
  2 | 
  3 | import { version } from "../package.json";
  4 | import { FLAGS_EXPIRE_MS } from "../src/config";
  5 | import { FlagsClient, RawFlag } from "../src/flag/flags";
  6 | import { HttpClient } from "../src/httpClient";
  7 | 
  8 | import { flagsResult } from "./mocks/handlers";
  9 | import { newCache, TEST_STALE_MS } from "./flagCache.test";
 10 | import { testLogger } from "./testLogger";
 11 | 
 12 | beforeEach(() => {
 13 |   vi.useFakeTimers();
 14 |   vi.resetAllMocks();
 15 | });
 16 | 
 17 | afterAll(() => {
 18 |   vi.useRealTimers();
 19 | });
 20 | 
 21 | function flagsClientFactory() {
 22 |   const { cache } = newCache();
 23 |   const httpClient = new HttpClient("pk", {
 24 |     baseUrl: "https://front.reflag.com",
 25 |   });
 26 | 
 27 |   vi.spyOn(httpClient, "get");
 28 |   vi.spyOn(httpClient, "post");
 29 | 
 30 |   return {
 31 |     cache,
 32 |     httpClient,
 33 |     newFlagsClient: function newFlagsClient(
 34 |       context?: Record<string, any>,
 35 |       options?: { staleWhileRevalidate?: boolean; fallbackFlags?: any },
 36 |     ) {
 37 |       return new FlagsClient(
 38 |         httpClient,
 39 |         {
 40 |           user: { id: "123" },
 41 |           company: { id: "456" },
 42 |           other: { eventId: "big-conference1" },
 43 |           ...context,
 44 |         },
 45 |         testLogger,
 46 |         {
 47 |           cache,
 48 |           ...options,
 49 |         },
 50 |       );
 51 |     },
 52 |   };
 53 | }
 54 | 
 55 | describe("FlagsClient", () => {
 56 |   beforeEach(() => {
 57 |     vi.clearAllMocks();
 58 |   });
 59 | 
 60 |   test("fetches flags", async () => {
 61 |     const { newFlagsClient, httpClient } = flagsClientFactory();
 62 |     const flagsClient = newFlagsClient();
 63 | 
 64 |     let updated = false;
 65 |     flagsClient.onUpdated(() => {
 66 |       updated = true;
 67 |     });
 68 | 
 69 |     await flagsClient.initialize();
 70 |     expect(flagsClient.getFlags()).toEqual(flagsResult);
 71 | 
 72 |     expect(updated).toBe(true);
 73 |     expect(httpClient.get).toBeCalledTimes(1);
 74 | 
 75 |     const calls = vi.mocked(httpClient.get).mock.calls.at(0)!;
 76 |     const { params, path, timeoutMs } = calls[0];
 77 | 
 78 |     const paramsObj = Object.fromEntries(new URLSearchParams(params));
 79 |     expect(paramsObj).toEqual({
 80 |       "reflag-sdk-version": "browser-sdk/" + version,
 81 |       "context.user.id": "123",
 82 |       "context.company.id": "456",
 83 |       "context.other.eventId": "big-conference1",
 84 |       publishableKey: "pk",
 85 |     });
 86 | 
 87 |     expect(path).toEqual("/features/evaluated");
 88 |     expect(timeoutMs).toEqual(5000);
 89 |   });
 90 | 
 91 |   test("warns about missing context fields", async () => {
 92 |     const { newFlagsClient } = flagsClientFactory();
 93 |     const flagsClient = newFlagsClient();
 94 | 
 95 |     await flagsClient.initialize();
 96 | 
 97 |     expect(testLogger.warn).toHaveBeenCalledTimes(1);
 98 |     expect(testLogger.warn).toHaveBeenCalledWith(
 99 |       "[Flags] flag targeting rules might not be correctly evaluated due to missing context fields.",
100 |       {
101 |         flagA: ["field1", "field2"],
102 |         "flagB.config": ["field3"],
103 |       },
104 |     );
105 | 
106 |     vi.advanceTimersByTime(TEST_STALE_MS + 1);
107 | 
108 |     expect(testLogger.warn).toHaveBeenCalledTimes(1);
109 |     vi.advanceTimersByTime(60 * 1000);
110 |     await flagsClient.initialize();
111 |     expect(testLogger.warn).toHaveBeenCalledTimes(2);
112 |   });
113 | 
114 |   test("ignores undefined context", async () => {
115 |     const { newFlagsClient, httpClient } = flagsClientFactory();
116 |     const flagsClient = newFlagsClient({
117 |       user: undefined,
118 |       company: undefined,
119 |       other: undefined,
120 |     });
121 |     await flagsClient.initialize();
122 |     expect(flagsClient.getFlags()).toEqual(flagsResult);
123 | 
124 |     expect(httpClient.get).toBeCalledTimes(1);
125 |     const calls = vi.mocked(httpClient.get).mock.calls.at(0);
126 |     const { params, path, timeoutMs } = calls![0];
127 | 
128 |     const paramsObj = Object.fromEntries(new URLSearchParams(params));
129 |     expect(paramsObj).toEqual({
130 |       "reflag-sdk-version": "browser-sdk/" + version,
131 |       publishableKey: "pk",
132 |     });
133 | 
134 |     expect(path).toEqual("/features/evaluated");
135 |     expect(timeoutMs).toEqual(5000);
136 |   });
137 | 
138 |   test("return fallback flags on failure (string list)", async () => {
139 |     const { newFlagsClient, httpClient } = flagsClientFactory();
140 | 
141 |     vi.mocked(httpClient.get).mockRejectedValue(
142 |       new Error("Failed to fetch flags"),
143 |     );
144 | 
145 |     const flagsClient = newFlagsClient(undefined, {
146 |       fallbackFlags: ["huddle"],
147 |     });
148 | 
149 |     await flagsClient.initialize();
150 |     expect(flagsClient.getFlags()).toStrictEqual({
151 |       huddle: {
152 |         isEnabled: true,
153 |         config: undefined,
154 |         key: "huddle",
155 |         isEnabledOverride: null,
156 |       },
157 |     });
158 |   });
159 | 
160 |   test("return fallback flags on failure (record)", async () => {
161 |     const { newFlagsClient, httpClient } = flagsClientFactory();
162 | 
163 |     vi.mocked(httpClient.get).mockRejectedValue(
164 |       new Error("Failed to fetch flags"),
165 |     );
166 |     const flagsClient = newFlagsClient(undefined, {
167 |       fallbackFlags: {
168 |         huddle: {
169 |           key: "john",
170 |           payload: { something: "else" },
171 |         },
172 |         zoom: true,
173 |       },
174 |     });
175 | 
176 |     await flagsClient.initialize();
177 |     expect(flagsClient.getFlags()).toStrictEqual({
178 |       huddle: {
179 |         isEnabled: true,
180 |         config: { key: "john", payload: { something: "else" } },
181 |         key: "huddle",
182 |         isEnabledOverride: null,
183 |       },
184 |       zoom: {
185 |         isEnabled: true,
186 |         config: undefined,
187 |         key: "zoom",
188 |         isEnabledOverride: null,
189 |       },
190 |     });
191 |   });
192 | 
193 |   test("caches response", async () => {
194 |     const { newFlagsClient, httpClient } = flagsClientFactory();
195 | 
196 |     const flagsClient1 = newFlagsClient();
197 |     await flagsClient1.initialize();
198 | 
199 |     expect(httpClient.get).toBeCalledTimes(1);
200 | 
201 |     const flagsClient2 = newFlagsClient();
202 |     await flagsClient2.initialize();
203 | 
204 |     const flags = flagsClient2.getFlags();
205 | 
206 |     expect(flags).toEqual(flagsResult);
207 |     expect(httpClient.get).toBeCalledTimes(1);
208 |   });
209 | 
210 |   test("use cache when unable to fetch flags", async () => {
211 |     const { newFlagsClient, httpClient } = flagsClientFactory();
212 |     const flagsClient = newFlagsClient({ staleWhileRevalidate: false });
213 |     await flagsClient.initialize(); // cache them initially
214 | 
215 |     vi.mocked(httpClient.get).mockRejectedValue(
216 |       new Error("Failed to fetch flags"),
217 |     );
218 |     expect(httpClient.get).toBeCalledTimes(1);
219 | 
220 |     vi.advanceTimersByTime(TEST_STALE_MS + 1);
221 | 
222 |     // fail this time
223 |     await flagsClient.fetchFlags();
224 |     expect(httpClient.get).toBeCalledTimes(2);
225 | 
226 |     const staleFlags = flagsClient.getFlags();
227 |     expect(staleFlags).toEqual(flagsResult);
228 |   });
229 | 
230 |   test("stale-while-revalidate should cache but start new fetch", async () => {
231 |     const response = {
232 |       success: true,
233 |       features: {
234 |         flagB: {
235 |           isEnabled: true,
236 |           key: "flagB",
237 |           targetingVersion: 1,
238 |         } satisfies RawFlag,
239 |       },
240 |     };
241 | 
242 |     const { newFlagsClient, httpClient } = flagsClientFactory();
243 | 
244 |     vi.mocked(httpClient.get).mockResolvedValue({
245 |       status: 200,
246 |       ok: true,
247 |       json: function () {
248 |         return Promise.resolve(response);
249 |       },
250 |     } as Response);
251 | 
252 |     const client = newFlagsClient({
253 |       staleWhileRevalidate: true,
254 |     });
255 |     expect(httpClient.get).toHaveBeenCalledTimes(0);
256 | 
257 |     await client.initialize();
258 |     expect(client.getFlags()).toEqual({
259 |       flagB: {
260 |         isEnabled: true,
261 |         key: "flagB",
262 |         targetingVersion: 1,
263 |         isEnabledOverride: null,
264 |       } satisfies RawFlag,
265 |     });
266 | 
267 |     expect(httpClient.get).toHaveBeenCalledTimes(1);
268 |     const client2 = newFlagsClient({
269 |       staleWhileRevalidate: true,
270 |     });
271 | 
272 |     // change the response so we can validate that we'll serve the stale cache
273 |     vi.mocked(httpClient.get).mockResolvedValue({
274 |       status: 200,
275 |       ok: true,
276 |       json: () =>
277 |         Promise.resolve({
278 |           success: true,
279 |           features: {
280 |             flagA: {
281 |               isEnabled: true,
282 |               key: "flagA",
283 |               targetingVersion: 1,
284 |             },
285 |           },
286 |         }),
287 |     } as Response);
288 | 
289 |     vi.advanceTimersByTime(TEST_STALE_MS + 1);
290 | 
291 |     await client2.initialize();
292 | 
293 |     // new fetch was fired in the background
294 |     expect(httpClient.get).toHaveBeenCalledTimes(2);
295 | 
296 |     await vi.waitFor(() =>
297 |       expect(client2.getFlags()).toEqual({
298 |         flagA: {
299 |           isEnabled: true,
300 |           targetingVersion: 1,
301 |           key: "flagA",
302 |           isEnabledOverride: null,
303 |         } satisfies RawFlag,
304 |       }),
305 |     );
306 |   });
307 | 
308 |   test("expires cache eventually", async () => {
309 |     // change the response so we can validate that we'll serve the stale cache
310 |     const { newFlagsClient, httpClient } = flagsClientFactory();
311 |     const client = newFlagsClient();
312 |     await client.initialize();
313 |     const a = client.getFlags();
314 | 
315 |     vi.advanceTimersByTime(FLAGS_EXPIRE_MS + 1);
316 |     vi.mocked(httpClient.get).mockResolvedValue({
317 |       status: 200,
318 |       ok: true,
319 |       json: () =>
320 |         Promise.resolve({
321 |           success: true,
322 |           features: {
323 |             flagB: { isEnabled: true, key: "flagB" },
324 |           },
325 |         }),
326 |     } as Response);
327 |     const client2 = newFlagsClient();
328 |     await client2.initialize();
329 | 
330 |     const b = client2.getFlags();
331 | 
332 |     expect(httpClient.get).toHaveBeenCalledTimes(2);
333 |     expect(a).not.toEqual(b);
334 |   });
335 | 
336 |   test("handled overrides", async () => {
337 |     // change the response so we can validate that we'll serve the stale cache
338 |     const { newFlagsClient } = flagsClientFactory();
339 |     // localStorage.clear();
340 |     const client = newFlagsClient();
341 |     await client.initialize();
342 | 
343 |     let updated = false;
344 |     client.onUpdated(() => {
345 |       updated = true;
346 |     });
347 | 
348 |     expect(client.getFlags().flagA.isEnabled).toBe(true);
349 |     expect(client.getFlags().flagA.isEnabledOverride).toBe(null);
350 | 
351 |     expect(updated).toBe(false);
352 | 
353 |     client.setFlagOverride("flagA", false);
354 | 
355 |     expect(updated).toBe(true);
356 |     expect(client.getFlags().flagA.isEnabled).toBe(true);
357 |     expect(client.getFlags().flagA.isEnabledOverride).toBe(false);
358 |   });
359 | 
360 |   test("ignores overrides for flags not returned by API", async () => {
361 |     // change the response so we can validate that we'll serve the stale cache
362 |     const { newFlagsClient } = flagsClientFactory();
363 | 
364 |     // localStorage.clear();
365 |     const client = newFlagsClient(undefined);
366 |     await client.initialize();
367 | 
368 |     let updated = false;
369 |     client.onUpdated(() => {
370 |       updated = true;
371 |     });
372 | 
373 |     expect(client.getFlags().flagB.isEnabled).toBe(true);
374 |     expect(client.getFlags().flagB.isEnabledOverride).toBe(null);
375 | 
376 |     // Setting an override for a flag that doesn't exist in fetched flags
377 |     // should not trigger an update since the merged flags don't change
378 |     client.setFlagOverride("flagC", true);
379 | 
380 |     expect(updated).toBe(false);
381 |     expect(client.getFlags().flagC).toBeUndefined();
382 |   });
383 | 
384 |   describe("pre-fetched flags", () => {
385 |     test("should have flags available when bootstrapped flags are provided in constructor", () => {
386 |       const { httpClient } = flagsClientFactory();
387 |       const preFetchedFlags = {
388 |         testFlag: {
389 |           key: "testFlag",
390 |           isEnabled: true,
391 |           targetingVersion: 1,
392 |         },
393 |         configFlag: {
394 |           key: "configFlag",
395 |           isEnabled: false,
396 |           targetingVersion: 2,
397 |           config: {
398 |             key: "config1",
399 |             version: 1,
400 |             payload: { value: "test" },
401 |           },
402 |         },
403 |       };
404 | 
405 |       const flagsClient = new FlagsClient(
406 |         httpClient,
407 |         {
408 |           user: { id: "123" },
409 |           company: { id: "456" },
410 |           other: { eventId: "big-conference1" },
411 |         },
412 |         testLogger,
413 |         {
414 |           bootstrappedFlags: preFetchedFlags,
415 |         },
416 |       );
417 | 
418 |       // Should be bootstrapped but not initialized until initialize() is called
419 |       expect(flagsClient["bootstrapped"]).toBe(true);
420 |       expect(flagsClient["initialized"]).toBe(false);
421 | 
422 |       // Should have the flags available even before initialize()
423 |       expect(flagsClient.getFlags()).toEqual({
424 |         testFlag: {
425 |           key: "testFlag",
426 |           isEnabled: true,
427 |           targetingVersion: 1,
428 |           isEnabledOverride: null,
429 |         },
430 |         configFlag: {
431 |           key: "configFlag",
432 |           isEnabled: false,
433 |           targetingVersion: 2,
434 |           config: {
435 |             key: "config1",
436 |             version: 1,
437 |             payload: { value: "test" },
438 |           },
439 |           isEnabledOverride: null,
440 |         },
441 |       });
442 |     });
443 | 
444 |     test("should skip fetching when already initialized with pre-fetched flags", async () => {
445 |       const { httpClient } = flagsClientFactory();
446 |       vi.spyOn(httpClient, "get");
447 | 
448 |       const preFetchedFlags = {
449 |         testFlag: {
450 |           key: "testFlag",
451 |           isEnabled: true,
452 |           targetingVersion: 1,
453 |         },
454 |       };
455 | 
456 |       const flagsClient = new FlagsClient(
457 |         httpClient,
458 |         {
459 |           user: { id: "123" },
460 |           company: { id: "456" },
461 |           other: { eventId: "big-conference1" },
462 |         },
463 |         testLogger,
464 |         {
465 |           bootstrappedFlags: preFetchedFlags,
466 |         },
467 |       );
468 | 
469 |       // Call initialize() after flags are already provided
470 |       await flagsClient.initialize();
471 | 
472 |       // Should not have made any HTTP requests since already initialized
473 |       expect(httpClient.get).not.toHaveBeenCalled();
474 | 
475 |       // Should still have the flags available
476 |       expect(flagsClient.getFlags()).toEqual({
477 |         testFlag: {
478 |           key: "testFlag",
479 |           isEnabled: true,
480 |           targetingVersion: 1,
481 |           isEnabledOverride: null,
482 |         },
483 |       });
484 |     });
485 | 
486 |     test("should trigger onUpdated when pre-fetched flags are set", async () => {
487 |       const { httpClient } = flagsClientFactory();
488 |       const preFetchedFlags = {
489 |         testFlag: {
490 |           key: "testFlag",
491 |           isEnabled: true,
492 |           targetingVersion: 1,
493 |         },
494 |       };
495 | 
496 |       const flagsClient = new FlagsClient(
497 |         httpClient,
498 |         {
499 |           user: { id: "123" },
500 |           company: { id: "456" },
501 |           other: { eventId: "big-conference1" },
502 |         },
503 |         testLogger,
504 |         {
505 |           bootstrappedFlags: preFetchedFlags,
506 |         },
507 |       );
508 | 
509 |       let updateTriggered = false;
510 |       flagsClient.onUpdated(() => {
511 |         updateTriggered = true;
512 |       });
513 | 
514 |       // Trigger the flags updated event by setting context (which should still fetch)
515 |       await flagsClient.setContext({
516 |         user: { id: "456" },
517 |         company: { id: "789" },
518 |         other: { eventId: "other-conference" },
519 |       });
520 | 
521 |       expect(updateTriggered).toBe(true);
522 |     });
523 | 
524 |     test("should work with fallback flags when initialization fails", async () => {
525 |       const { httpClient } = flagsClientFactory();
526 |       vi.spyOn(httpClient, "get").mockRejectedValue(
527 |         new Error("Failed to fetch flags"),
528 |       );
529 | 
530 |       const preFetchedFlags = {
531 |         testFlag: {
532 |           key: "testFlag",
533 |           isEnabled: true,
534 |           targetingVersion: 1,
535 |         },
536 |       };
537 | 
538 |       const flagsClient = new FlagsClient(
539 |         httpClient,
540 |         {
541 |           user: { id: "123" },
542 |           company: { id: "456" },
543 |           other: { eventId: "big-conference1" },
544 |         },
545 |         testLogger,
546 |         {
547 |           bootstrappedFlags: preFetchedFlags,
548 |           fallbackFlags: ["fallbackFlag"],
549 |         },
550 |       );
551 | 
552 |       // Should be bootstrapped but not initialized until initialize() is called
553 |       expect(flagsClient["bootstrapped"]).toBe(true);
554 |       expect(flagsClient["initialized"]).toBe(false);
555 |       expect(flagsClient.getFlags()).toEqual({
556 |         testFlag: {
557 |           key: "testFlag",
558 |           isEnabled: true,
559 |           targetingVersion: 1,
560 |           isEnabledOverride: null,
561 |         },
562 |       });
563 | 
564 |       // Calling initialize should not fetch since already bootstrapped
565 |       await flagsClient.initialize();
566 |       expect(httpClient.get).not.toHaveBeenCalled();
567 |       expect(flagsClient["initialized"]).toBe(true);
568 |     });
569 |   });
570 | });
571 | 
```

--------------------------------------------------------------------------------
/packages/browser-sdk/FEEDBACK.md:
--------------------------------------------------------------------------------

```markdown
  1 | # Reflag Feedback UI
  2 | 
  3 | The Reflag Browser SDK includes a UI you can use to collect feedback from user
  4 | about particular flags.
  5 | 
  6 | ![image](https://github.com/reflagcom/javascript/assets/34348/c387bac1-f2e2-4efd-9dda-5030d76f9532)
  7 | 
  8 | ## Global feedback configuration
  9 | 
 10 | The Reflag Browser SDK feedback UI is configured with reasonable defaults,
 11 | positioning itself as a [dialog](#dialog) in the lower right-hand corner of
 12 | the viewport, displayed in English, and with a [light-mode theme](#custom-styling).
 13 | 
 14 | These settings can be overwritten when initializing the Reflag Browser SDK:
 15 | 
 16 | ```typescript
 17 | const reflag = new ReflagClient({
 18 |   publishableKey: "reflag-publishable-key",
 19 |   user: { id: "42" },
 20 |   feedback: {
 21 |     ui: {
 22 |       position: POSITION_CONFIG, // See positioning section
 23 |       translations: TRANSLATION_KEYS, // See internationalization section
 24 | 
 25 |       // Enable automated feedback surveys. Default: `true`
 26 |       enableAutoFeedback: boolean,
 27 | 
 28 |       /**
 29 |        * Do your own feedback prompt handling or override
 30 |        * default settings at runtime.
 31 |        */
 32 |       autoFeedbackHandler: (promptMessage, handlers) => {
 33 |         // See Automated Feedback Surveys section
 34 |       },
 35 |     },
 36 |   },
 37 | });
 38 | ```
 39 | 
 40 | See also:
 41 | 
 42 | - [Positioning and behavior](#positioning-and-behavior) for the position option,
 43 | - [Static language configuration](#static-language-configuration) if you want to translate the feedback UI,
 44 | - [Automated feedback surveys](#automated-feedback-surveys) to override default configuration.
 45 | 
 46 | ## Automated feedback surveys
 47 | 
 48 | Automated feedback surveys are enabled by default.
 49 | 
 50 | When automated feedback surveys are enabled, the Reflag Browser SDK
 51 | will open and maintain a connection to the Reflag service. When a user
 52 | triggers an event tracked by a flag and is eligible to be prompted
 53 | for feedback, the Reflag service will send a request to the SDK instance.
 54 | By default, this request will open up the Reflag feedback UI in the user's
 55 | browser, but you can intercept the request and override this behavior.
 56 | 
 57 | The live connection for automated feedback is established when the
 58 | `ReflagClient` is initialized.
 59 | 
 60 | ### Disabling automated feedback surveys
 61 | 
 62 | You can disable automated collection in the `ReflagClient` constructor:
 63 | 
 64 | ```typescript
 65 | const reflag = new ReflagClient({
 66 |   publishableKey: "reflag-publishable-key",
 67 |   user: { id: "42" },
 68 |   feedback: {
 69 |     enableAutoFeedback: false,
 70 |   },
 71 | });
 72 | ```
 73 | 
 74 | ### Overriding prompt event defaults
 75 | 
 76 | If you are not satisfied with the default UI behavior when an automated prompt
 77 | event arrives, you can can [override the global defaults](#global-feedback-configuration)
 78 | or intercept and override settings at runtime like this:
 79 | 
 80 | ```javascript
 81 | const reflag = new ReflagClient({
 82 |   publishableKey: "reflag-publishable-key",
 83 |   user: { id: "42" },
 84 |   feedback: {
 85 |     autoFeedbackHandler: (promptMessage, handlers) => {
 86 |       // Pass your overrides here. Everything is optional
 87 |       handlers.openFeedbackForm({
 88 |         title: promptMessage.question,
 89 | 
 90 |         position: POSITION_CONFIG, // See positioning section
 91 |         translations: TRANSLATION_KEYS, // See internationalization section
 92 | 
 93 |         // Trigger side effects with the collected data,
 94 |         // for example posting it back into your own CRM
 95 |         onAfterSubmit: (feedback) => {
 96 |           storeFeedbackInCRM({
 97 |             score: feedback.score,
 98 |             comment: feedback.comment,
 99 |           });
100 |         },
101 |       });
102 |     },
103 |   },
104 | });
105 | ```
106 | 
107 | See also:
108 | 
109 | - [Positioning and behavior](#positioning-and-behavior) for the position option.
110 | - [Runtime language configuration](#runtime-language-configuration) if you want
111 |   to translate the feedback UI.
112 | - [Use your own UI to collect feedback](#using-your-own-ui-to-collect-feedback) if
113 |   the feedback UI doesn't match your design.
114 | 
115 | ## Manual feedback collection
116 | 
117 | To open up the feedback collection UI, call `reflagClient.requestFeedback(options)`
118 | with the appropriate options. This approach is particularly beneficial if you wish
119 | to retain manual control over feedback collection from your users while leveraging
120 | the convenience of the Reflag feedback UI to reduce the amount of code you need
121 | to maintain.
122 | 
123 | Examples of this could be if you want the click of a `give us feedback`-button
124 | or the end of a specific user flow, to trigger a pop-up displaying the feedback
125 | user interface.
126 | 
127 | ### reflagClient.requestFeedback() options
128 | 
129 | Minimal usage with defaults:
130 | 
131 | ```javascript
132 | reflagClient.requestFeedback({
133 |   flagKey: "reflag-flag-key",
134 |   title: "How satisfied are you with file uploads?",
135 | });
136 | ```
137 | 
138 | All options:
139 | 
140 | ```javascript
141 | reflagClient.requestFeedback({
142 |   flagKey: "reflag-flag-key", // [Required]
143 |   userId: "your-user-id",  // [Optional] if user persistence is
144 |                            // enabled (default in browsers),
145 |   companyId: "users-company-or-account-id", // [Optional]
146 |   title: "How satisfied are you with file uploads?" // [Optional]
147 | 
148 |   position: POSITION_CONFIG, // [Optional] see the positioning section
149 |   translations: TRANSLATION_KEYS // [Optional] see the internationalization section
150 | 
151 |   // [Optional] trigger side effects with the collected data,
152 |   // for example sending the feedback to your own CRM
153 |   onAfterSubmit: (feedback) => {
154 |     storeFeedbackInCRM({
155 |       score: feedback.score,
156 |       comment: feedback.comment
157 |     })
158 |   }
159 | })
160 | ```
161 | 
162 | See also:
163 | 
164 | - [Positioning and behavior](#positioning-and-behavior) for the position option.
165 | - [Runtime language configuration](#runtime-language-configuration) if
166 |   you want to translate the feedback UI.
167 | 
168 | ## Positioning and behavior
169 | 
170 | The feedback UI can be configured to be placed and behave in 3 different ways:
171 | 
172 | ### Positioning configuration
173 | 
174 | #### Modal
175 | 
176 | A modal overlay with a backdrop that blocks interaction with the underlying
177 | page. It can be dismissed with the keyboard shortcut `<ESC>` or the dedicated
178 | close button in the top right corner. It is always centered on the page, capturing
179 | focus, and making it the primary interface the user needs to interact with.
180 | 
181 | ![image](https://github.com/reflagcom/javascript/assets/331790/6c6efbd3-cf7d-4d5b-b126-7ac978b2e512)
182 | 
183 | Using a modal is the strongest possible push for feedback. You are interrupting the
184 | user's normal flow, which can cause annoyance. A good use-case for the modal is
185 | when the user finishes a linear flow that they don't perform often, for example
186 | setting up a new account.
187 | 
188 | ```javascript
189 | position: {
190 |   type: "MODAL";
191 | }
192 | ```
193 | 
194 | #### Dialog
195 | 
196 | A dialog that appears in a specified corner of the viewport, without limiting the
197 | user's interaction with the rest of the page. It can be dismissed with the dedicated
198 | close button, but will automatically disappear after a short time period if the user
199 | does not interact with it.
200 | 
201 | ![image](https://github.com/reflagcom/javascript/assets/331790/30413513-fd5f-4a2c-852a-9b074fa4666c)
202 | 
203 | Using a dialog is a soft push for feedback. It lets the user continue their work
204 | with a minimal amount of intrusion. The user can opt-in to respond but is not
205 | required to. A good use case for this behavior is when a user uses a flag where
206 | the expected outcome is predictable, possibly because they have used it multiple
207 | times before. For example: Uploading a file, switching to a different view of a
208 | visualization, visiting a specific page, or manipulating some data.
209 | 
210 | The default feedback UI behavior is a dialog placed in the bottom right corner of
211 | the viewport.
212 | 
213 | ```typescript
214 | position: {
215 |   type: "DIALOG";
216 |   placement: "top-left" | "top-right" | "bottom-left" | "bottom-right";
217 |   offset?: {
218 |     x?: string | number; // e.g. "-5rem", "10px" or 10 (pixels)
219 |     y?: string | number;
220 |   }
221 | }
222 | ```
223 | 
224 | #### Popover
225 | 
226 | A popover that is anchored relative to a DOM-element (typically a button). It can
227 | be dismissed by clicking outside the popover or by pressing the dedicated close button.
228 | 
229 | ![image](https://github.com/reflagcom/javascript/assets/331790/4c5c5597-9ed3-4d4d-90c0-950926d0d967)
230 | 
231 | You can use the popover mode to implement your own button to collect feedback manually.
232 | 
233 | ```typescript
234 | type Position = {
235 |   type: "POPOVER";
236 |   anchor: DOMElement;
237 | };
238 | ```
239 | 
240 | Popover feedback button example:
241 | 
242 | ```html
243 | <button id="feedbackButton">Tell us what you think</button>
244 | <script>
245 |   const button = document.getElementById("feedbackButton");
246 |   button.addEventListener("click", (e) => {
247 |     reflagClient.requestFeedback({
248 |       flagKey: "reflag-flag-key",
249 |       userId: "your-user-id",
250 |       title: "How do you like the popover?",
251 |       position: {
252 |         type: "POPOVER",
253 |         anchor: e.currentTarget,
254 |       },
255 |     });
256 |   });
257 | </script>
258 | ```
259 | 
260 | ## Internationalization (i18n)
261 | 
262 | By default, the feedback UI is written in English. However, you can supply your own
263 | translations by passing an object in the options to either or both of the
264 | `new ReflagClient(options)` or `reflagClient.requestFeedback(options)` calls.
265 | These translations will replace the English ones used by the feedback interface.
266 | See examples below.
267 | 
268 | ![image](https://github.com/reflagcom/javascript/assets/331790/68805b38-e9f6-4de5-9f55-188216983e3c)
269 | 
270 | See [default English localization keys](https://github.com/reflagcom/javascript/tree/main/packages/browser-sdk/src/feedback/ui/config/defaultTranslations.tsx)
271 | for a reference of what translation keys can be supplied.
272 | 
273 | ### Static language configuration
274 | 
275 | If you know the language at page load, you can configure your translation keys while
276 | initializing the Reflag Browser SDK:
277 | 
278 | ```typescript
279 | new ReflagClient({
280 |   publishableKey: "my-publishable-key",
281 |   feedback: {
282 |     ui: {
283 |       translations: {
284 |         DefaultQuestionLabel:
285 |           "Dans quelle mesure êtes-vous satisfait de cette fonctionnalité ?",
286 |         QuestionPlaceholder:
287 |           "Comment pouvons-nous améliorer cette fonctionnalité ?",
288 |         ScoreStatusDescription: "Choisissez une note et laissez un commentaire",
289 |         ScoreStatusLoading: "Chargement...",
290 |         ScoreStatusReceived: "La note a été reçue !",
291 |         ScoreVeryDissatisfiedLabel: "Très insatisfait",
292 |         ScoreDissatisfiedLabel: "Insatisfait",
293 |         ScoreNeutralLabel: "Neutre",
294 |         ScoreSatisfiedLabel: "Satisfait",
295 |         ScoreVerySatisfiedLabel: "Très satisfait",
296 |         SuccessMessage: "Merci d'avoir envoyé vos commentaires!",
297 |         SendButton: "Envoyer",
298 |       },
299 |     },
300 |   },
301 | });
302 | ```
303 | 
304 | ### Runtime language configuration
305 | 
306 | If you only know the user's language after the page has loaded, you can provide
307 | translations to either the `reflagClient.requestFeedback(options)` call or
308 | the `autoFeedbackHandler` option before the feedback interface opens.
309 | See examples below.
310 | 
311 | ```typescript
312 | reflagClient.requestFeedback({
313 |   ... // Other options
314 |   translations: {
315 |     // your translation keys
316 |   }
317 | })
318 | ```
319 | 
320 | ### Translations
321 | 
322 | When you are collecting feedback through the Reflag automation, you can intercept
323 | the default prompt handling and override the defaults.
324 | 
325 | If you set the prompt question in the Reflag app to be one of your own translation
326 | keys, you can even get a translated version of the question you want to ask your
327 | customer in the feedback UI.
328 | 
329 | ```javascript
330 | new ReflagClient({
331 |   publishableKey: "reflag-publishable-key",
332 |   feedback: {
333 |     autoFeedbackHandler: (message, handlers) => {
334 |       const translatedQuestion =
335 |         i18nLookup[message.question] ?? message.question;
336 |       handlers.openFeedbackForm({
337 |         title: translatedQuestion,
338 |         translations: {
339 |           // your static translation keys
340 |         },
341 |       });
342 |     },
343 |   },
344 | });
345 | ```
346 | 
347 | ## Custom styling
348 | 
349 | You can adapt parts of the look of the Reflag feedback UI by applying CSS custom
350 | properties to your page in your CSS `:root`-scope.
351 | 
352 | For example, a dark mode theme might look like this:
353 | 
354 | ![image](https://github.com/reflagcom/javascript/assets/34348/5d579b7b-a830-4530-8b40-864488a8597e)
355 | 
356 | ```css
357 | :root {
358 |   --reflag-feedback-dialog-background-color: #1e1f24;
359 |   --reflag-feedback-dialog-color: rgba(255, 255, 255, 0.92);
360 |   --reflag-feedback-dialog-secondary-color: rgba(255, 255, 255, 0.3);
361 |   --reflag-feedback-dialog-border: rgba(255, 255, 255, 0.16);
362 |   --reflag-feedback-dialog-primary-button-background-color: #655bfa;
363 |   --reflag-feedback-dialog-primary-button-color: white;
364 |   --reflag-feedback-dialog-input-border-color: rgba(255, 255, 255, 0.16);
365 |   --reflag-feedback-dialog-input-focus-border-color: rgba(255, 255, 255, 0.3);
366 |   --reflag-feedback-dialog-error-color: #f56565;
367 | 
368 |   --reflag-feedback-dialog-rating-1-color: #ed8936;
369 |   --reflag-feedback-dialog-rating-1-background-color: #7b341e;
370 |   --reflag-feedback-dialog-rating-2-color: #dd6b20;
371 |   --reflag-feedback-dialog-rating-2-background-color: #652b19;
372 |   --reflag-feedback-dialog-rating-3-color: #787c91;
373 |   --reflag-feedback-dialog-rating-3-background-color: #3e404c;
374 |   --reflag-feedback-dialog-rating-4-color: #38a169;
375 |   --reflag-feedback-dialog-rating-4-background-color: #1c4532;
376 |   --reflag-feedback-dialog-rating-5-color: #48bb78;
377 |   --reflag-feedback-dialog-rating-5-background-color: #22543d;
378 | 
379 |   --reflag-feedback-dialog-submitted-check-background-color: #38a169;
380 |   --reflag-feedback-dialog-submitted-check-color: #ffffff;
381 | }
382 | ```
383 | 
384 | 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).
385 | 
386 | ## Using your own UI to collect feedback
387 | 
388 | You may have very strict design guidelines for your app and maybe the Reflag feedback
389 | UI doesn't quite work for you. In this case, you can implement your own feedback
390 | collection mechanism, which follows your own design guidelines. This is the data
391 | type you need to collect:
392 | 
393 | ```typescript
394 | type DataToCollect = {
395 |   // Customer satisfaction score
396 |   score?: 1 | 2 | 3 | 4 | 5;
397 | 
398 |   // The comment.
399 |   comment?: string;
400 | };
401 | ```
402 | 
403 | Either `score` or `comment` must be defined in order to pass validation in the
404 | Reflag API.
405 | 
406 | ### Manual feedback collection with custom UI
407 | 
408 | Examples of a HTML-form that collects the relevant data can be found
409 | 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).
410 | 
411 | Once you have collected the feedback data, pass it along to `reflagClient.feedback()`:
412 | 
413 | ```javascript
414 | reflagClient.feedback({
415 |   flagKey: "reflag-flag-key",
416 |   userId: "your-user-id",
417 |   score: 5,
418 |   comment: "Best thing I've ever tried!",
419 | });
420 | ```
421 | 
422 | ### Intercepting automated feedback survey events
423 | 
424 | When using automated feedback surveys, the Reflag service will, when specified,
425 | send a feedback prompt message to your user's instance of the Reflag Browser SDK.
426 | This will result in the feedback UI being opened.
427 | 
428 | You can intercept this behavior and open your own custom feedback collection form:
429 | 
430 | ```typescript
431 | new ReflagClient({
432 |   publishableKey: "reflag-publishable-key",
433 |   feedback: {
434 |     autoFeedbackHandler: async (promptMessage, handlers) => {
435 |       // This opens your custom UI
436 |       customFeedbackCollection({
437 |         // The question configured in the Reflag UI for the flag
438 |         question: promptMessage.question,
439 |         // When the user successfully submits feedback data.
440 |         // Use this instead of `reflagClient.feedback()`, otherwise
441 |         // the feedback prompt handler will keep being called
442 |         // with the same prompt message
443 |         onFeedbackSubmitted: (feedback) => {
444 |           handlers.reply(feedback);
445 |         },
446 |         // When the user closes the custom feedback form
447 |         // without leaving any response.
448 |         // It is important to feed this back, otherwise
449 |         // the feedback prompt handler will keep being called
450 |         // with the same prompt message
451 |         onFeedbackDismissed: () => {
452 |           handlers.reply(null);
453 |         },
454 |       });
455 |     },
456 |   },
457 | });
458 | ```
459 | 
```

--------------------------------------------------------------------------------
/packages/react-sdk/src/index.tsx:
--------------------------------------------------------------------------------

```typescript
  1 | "use client";
  2 | 
  3 | import React, {
  4 |   createContext,
  5 |   ReactNode,
  6 |   useContext,
  7 |   useEffect,
  8 |   useMemo,
  9 |   useState,
 10 | } from "react";
 11 | 
 12 | import {
 13 |   CheckEvent,
 14 |   CompanyContext,
 15 |   HookArgs,
 16 |   InitOptions,
 17 |   RawFlag,
 18 |   ReflagClient,
 19 |   ReflagContext,
 20 |   RequestFeedbackData,
 21 |   TrackEvent,
 22 |   UnassignedFeedback,
 23 |   UserContext,
 24 | } from "@reflag/browser-sdk";
 25 | 
 26 | import { version } from "../package.json";
 27 | 
 28 | export type { CheckEvent, CompanyContext, TrackEvent, UserContext };
 29 | 
 30 | export type EmptyFlagRemoteConfig = { key: undefined; payload: undefined };
 31 | 
 32 | export type FlagType = {
 33 |   config?: {
 34 |     payload: any;
 35 |   };
 36 | };
 37 | 
 38 | /**
 39 |  * A remotely managed configuration value for a feature.
 40 |  */
 41 | export type FlagRemoteConfig =
 42 |   | {
 43 |       /**
 44 |        * The key of the matched configuration value.
 45 |        */
 46 |       key: string;
 47 | 
 48 |       /**
 49 |        * The optional user-supplied payload data.
 50 |        */
 51 |       payload: any;
 52 |     }
 53 |   | EmptyFlagRemoteConfig;
 54 | 
 55 | /**
 56 |  * Describes a feature
 57 |  */
 58 | export interface Flag<
 59 |   TConfig extends FlagType["config"] = EmptyFlagRemoteConfig,
 60 | > {
 61 |   /**
 62 |    * The key of the feature.
 63 |    */
 64 |   key: string;
 65 | 
 66 |   /**
 67 |    * If the feature is enabled.
 68 |    */
 69 |   isEnabled: boolean;
 70 | 
 71 |   /**
 72 |    * If the feature is loading.
 73 |    */
 74 |   isLoading: boolean;
 75 | 
 76 |   /*
 77 |    * Optional user-defined configuration.
 78 |    */
 79 |   config:
 80 |     | ({
 81 |         key: string;
 82 |       } & TConfig)
 83 |     | EmptyFlagRemoteConfig;
 84 | 
 85 |   /**
 86 |    * Track feature usage in Reflag.
 87 |    */
 88 |   track(): Promise<Response | undefined> | undefined;
 89 |   /**
 90 |    * Request feedback from the user.
 91 |    */
 92 |   requestFeedback: (opts: RequestFeedbackOptions) => void;
 93 | }
 94 | 
 95 | // eslint-disable-next-line @typescript-eslint/no-empty-object-type
 96 | export interface Flags {}
 97 | 
 98 | /**
 99 |  * Describes a collection of evaluated feature.
100 |  *
101 |  * @remarks
102 |  * This types falls back to a generic Record<string, Flag> if the Flags interface
103 |  * has not been extended.
104 |  *
105 |  */
106 | export type TypedFlags = keyof Flags extends never
107 |   ? Record<string, Flag>
108 |   : {
109 |       [TypedFlagKey in keyof Flags]: Flags[TypedFlagKey] extends FlagType
110 |         ? Flag<Flags[TypedFlagKey]["config"]>
111 |         : Flag;
112 |     };
113 | 
114 | export type FlagKey = keyof TypedFlags;
115 | 
116 | /**
117 |  * Describes a collection of evaluated raw flags.
118 |  */
119 | export type RawFlags = Record<FlagKey, RawFlag>;
120 | 
121 | export type BootstrappedFlags = {
122 |   context: ReflagContext;
123 |   flags: RawFlags;
124 | };
125 | 
126 | const SDK_VERSION = `react-sdk/${version}`;
127 | 
128 | /**
129 |  * Base props for the ReflagProvider and ReflagBootstrappedProvider.
130 |  * @internal
131 |  */
132 | export type ReflagPropsBase = {
133 |   /**
134 |    * The children to render after the client is initialized.
135 |    */
136 |   children?: ReactNode;
137 | 
138 |   /**
139 |    * A React component to show while the client is initializing.
140 |    */
141 |   loadingComponent?: ReactNode;
142 | 
143 |   /**
144 |    * Set to `true` to show the loading component while the client is initializing.
145 |    */
146 |   initialLoading?: boolean;
147 | 
148 |   /**
149 |    * Set to `true` to enable debug logging to the console,
150 |    */
151 |   debug?: boolean;
152 | };
153 | 
154 | /**
155 |  * Base init options for the ReflagProvider and ReflagBootstrappedProvider.
156 |  * @internal
157 |  */
158 | export type ReflagInitOptionsBase = Omit<
159 |   InitOptions,
160 |   "user" | "company" | "other" | "otherContext" | "bootstrappedFlags"
161 | >;
162 | 
163 | /**
164 |  * Map of clients by context key. Used to deduplicate initialization of the client.
165 |  * @internal
166 |  */
167 | const reflagClients = new Map<string, ReflagClient>();
168 | 
169 | /**
170 |  * Returns the ReflagClient for a given publishable key.
171 |  * Only creates a new ReflagClient is not already created or if it hook is run on the server.
172 |  * @internal
173 |  */
174 | function useReflagClient(initOptions: InitOptions, debug = false) {
175 |   const isServer = typeof window === "undefined";
176 |   if (isServer || !reflagClients.has(initOptions.publishableKey)) {
177 |     const client = new ReflagClient({
178 |       ...initOptions,
179 |       logger: debug ? console : undefined,
180 |       sdkVersion: SDK_VERSION,
181 |     });
182 |     if (!isServer) {
183 |       reflagClients.set(initOptions.publishableKey, client);
184 |     }
185 |     return client;
186 |   }
187 |   return reflagClients.get(initOptions.publishableKey)!;
188 | }
189 | 
190 | type ProviderContextType = {
191 |   isLoading: boolean;
192 |   client: ReflagClient;
193 | };
194 | 
195 | const ProviderContext = createContext<ProviderContextType | null>(null);
196 | 
197 | /**
198 |  * Props for the ReflagClientProvider.
199 |  */
200 | export type ReflagClientProviderProps = Omit<ReflagPropsBase, "debug"> & {
201 |   client: ReflagClient;
202 | };
203 | 
204 | export function ReflagClientProvider({
205 |   client,
206 |   loadingComponent,
207 |   initialLoading = true,
208 |   children,
209 | }: ReflagClientProviderProps) {
210 |   const [isLoading, setIsLoading] = useState(
211 |     client.getState() !== "initialized" ? initialLoading : false,
212 |   );
213 | 
214 |   useOnEvent(
215 |     "stateUpdated",
216 |     (state) => {
217 |       setIsLoading(state === "initializing");
218 |     },
219 |     client,
220 |   );
221 | 
222 |   return (
223 |     <ProviderContext.Provider
224 |       value={{
225 |         isLoading,
226 |         client,
227 |       }}
228 |     >
229 |       {isLoading && typeof loadingComponent !== "undefined"
230 |         ? loadingComponent
231 |         : children}
232 |     </ProviderContext.Provider>
233 |   );
234 | }
235 | 
236 | /**
237 |  * Props for the ReflagProvider.
238 |  */
239 | export type ReflagProps = ReflagPropsBase &
240 |   ReflagInitOptionsBase & {
241 |     /**
242 |      * The context to use for the ReflagClient containing user, company, and other context.
243 |      */
244 |     context?: ReflagContext;
245 | 
246 |     /**
247 |      * Company related context. If you provide `id` Reflag will enrich the evaluation context with
248 |      * company attributes on Reflag servers.
249 |      * @deprecated Use `context` instead, this property will be removed in the next major version
250 |      */
251 |     company?: CompanyContext;
252 | 
253 |     /**
254 |      * User related context. If you provide `id` Reflag will enrich the evaluation context with
255 |      * user attributes on Reflag servers.
256 |      * @deprecated Use `context` instead, this property will be removed in the next major version
257 |      */
258 |     user?: UserContext;
259 | 
260 |     /**
261 |      * Context which is not related to a user or a company.
262 |      * @deprecated Use `context` instead, this property will be removed in the next major version
263 |      */
264 |     otherContext?: Record<string, string | number | undefined>;
265 |   };
266 | 
267 | /**
268 |  * Provider for the ReflagClient.
269 |  */
270 | export function ReflagProvider({
271 |   children,
272 |   context,
273 |   user,
274 |   company,
275 |   otherContext,
276 |   loadingComponent,
277 |   initialLoading = true,
278 |   debug,
279 |   ...config
280 | }: ReflagProps) {
281 |   const resolvedContext = useMemo(
282 |     () => ({ user, company, other: otherContext, ...context }),
283 |     [user, company, otherContext, context],
284 |   );
285 |   const client = useReflagClient(
286 |     {
287 |       ...config,
288 |       ...resolvedContext,
289 |     },
290 |     debug,
291 |   );
292 | 
293 |   // Initialize the client if it is not already initialized
294 |   useEffect(() => {
295 |     if (client.getState() !== "idle") return;
296 |     void client.initialize().catch((e) => {
297 |       client.logger.error("failed to initialize client", e);
298 |     });
299 |   }, [client]);
300 | 
301 |   // Update the context if it changes
302 |   useEffect(() => {
303 |     void client.setContext(resolvedContext);
304 |   }, [client, resolvedContext]);
305 | 
306 |   return (
307 |     <ReflagClientProvider
308 |       client={client}
309 |       initialLoading={initialLoading}
310 |       loadingComponent={loadingComponent}
311 |     >
312 |       {children}
313 |     </ReflagClientProvider>
314 |   );
315 | }
316 | 
317 | /**
318 |  * Props for the ReflagBootstrappedProvider.
319 |  */
320 | export type ReflagBootstrappedProps = ReflagPropsBase &
321 |   ReflagInitOptionsBase & {
322 |     /**
323 |      * Pre-fetched flags to be used instead of fetching them from the server.
324 |      */
325 |     flags: BootstrappedFlags;
326 |   };
327 | 
328 | /**
329 |  * Bootstrapped Provider for the ReflagClient using pre-fetched flags.
330 |  */
331 | export function ReflagBootstrappedProvider({
332 |   flags,
333 |   children,
334 |   loadingComponent,
335 |   initialLoading = false,
336 |   debug,
337 |   ...config
338 | }: ReflagBootstrappedProps) {
339 |   const client = useReflagClient(
340 |     {
341 |       ...config,
342 |       ...flags.context,
343 |       bootstrappedFlags: flags.flags,
344 |     },
345 |     debug,
346 |   );
347 | 
348 |   // Initialize the client if it is not already initialized
349 |   useEffect(() => {
350 |     if (client.getState() !== "idle") return;
351 |     void client.initialize().catch((e) => {
352 |       client.logger.error("failed to initialize client", e);
353 |     });
354 |   }, [client]);
355 | 
356 |   // Update the context if it changes on the client side
357 |   useEffect(() => {
358 |     void client.setContext(flags.context);
359 |   }, [client, flags.context]);
360 | 
361 |   // Update the bootstrappedFlags if they change on the client side
362 |   useEffect(() => {
363 |     client.updateFlags(flags.flags);
364 |   }, [client, flags.flags]);
365 | 
366 |   return (
367 |     <ReflagClientProvider
368 |       client={client}
369 |       initialLoading={initialLoading}
370 |       loadingComponent={loadingComponent}
371 |     >
372 |       {children}
373 |     </ReflagClientProvider>
374 |   );
375 | }
376 | 
377 | export type RequestFeedbackOptions = Omit<
378 |   RequestFeedbackData,
379 |   "flagKey" | "featureId"
380 | >;
381 | 
382 | /**
383 |  * @deprecated use `useFlag` instead
384 |  */
385 | export function useFeature<TKey extends FlagKey>(key: TKey) {
386 |   return useFlag(key);
387 | }
388 | 
389 | /**
390 |  * Returns the state of a given feature for the current context, e.g.
391 |  *
392 |  * ```ts
393 |  * function HuddleButton() {
394 |  *   const {isEnabled, config: { payload }, track} = useFlag("huddle");
395 |  *   if (isEnabled) {
396 |  *    return <button onClick={() => track()}>{payload?.buttonTitle ?? "Start Huddle"}</button>;
397 |  * }
398 |  * ```
399 |  */
400 | export function useFlag<TKey extends FlagKey>(key: TKey): TypedFlags[TKey] {
401 |   const client = useClient();
402 |   const isLoading = useIsLoading();
403 |   const [flag, setFlag] = useState(client.getFlag(key));
404 | 
405 |   const track = () => client.track(key);
406 |   const requestFeedback = (opts: RequestFeedbackOptions) =>
407 |     client.requestFeedback({ ...opts, flagKey: key });
408 | 
409 |   useOnEvent(
410 |     "flagsUpdated",
411 |     () => {
412 |       setFlag(client.getFlag(key));
413 |     },
414 |     client,
415 |   );
416 | 
417 |   if (isLoading || !flag) {
418 |     return {
419 |       key,
420 |       isLoading,
421 |       isEnabled: false,
422 |       config: {
423 |         key: undefined,
424 |         payload: undefined,
425 |       } as TypedFlags[TKey]["config"],
426 |       track,
427 |       requestFeedback,
428 |     };
429 |   }
430 | 
431 |   return {
432 |     key,
433 |     isLoading,
434 |     track,
435 |     requestFeedback,
436 |     get isEnabled() {
437 |       return flag.isEnabled ?? false;
438 |     },
439 |     get config() {
440 |       return flag.config as TypedFlags[TKey]["config"];
441 |     },
442 |   };
443 | }
444 | 
445 | /**
446 |  * Returns a function to send an event when a user performs an action
447 |  * Note: When calling `useTrack`, user/company must already be set.
448 |  *
449 |  * ```ts
450 |  * const track = useTrack();
451 |  * track("Started Huddle", { button: "cta" });
452 |  * ```
453 |  */
454 | export function useTrack() {
455 |   const client = useClient();
456 |   return (eventName: string, attributes?: Record<string, any> | null) =>
457 |     client.track(eventName, attributes);
458 | }
459 | 
460 | /**
461 |  * Returns a function to open up the feedback form
462 |  * Note: When calling `useRequestFeedback`, user/company must already be set.
463 |  *
464 |  * See [link](../../browser-sdk/FEEDBACK.md#reflagclientrequestfeedback-options) for more information
465 |  *
466 |  * ```ts
467 |  * const requestFeedback = useRequestFeedback();
468 |  * reflag.requestFeedback({
469 |  *   flagKey: "file-uploads",
470 |  *   title: "How satisfied are you with file uploads?",
471 |  * });
472 |  * ```
473 |  */
474 | export function useRequestFeedback() {
475 |   const client = useClient();
476 |   return (options: RequestFeedbackData) => client.requestFeedback(options);
477 | }
478 | 
479 | /**
480 |  * Returns a function to manually send feedback collected from a user.
481 |  * Note: When calling `useSendFeedback`, user/company must already be set.
482 |  *
483 |  * See [link](./../../browser-sdk/FEEDBACK.md#using-your-own-ui-to-collect-feedback) for more information
484 |  *
485 |  * ```ts
486 |  * const sendFeedback = useSendFeedback();
487 |  * sendFeedback({
488 |  *   flagKey: "huddle";
489 |  *   question: "How did you like the new huddle feature?";
490 |  *   score: 5;
491 |  *   comment: "I loved it!";
492 |  * });
493 |  * ```
494 |  */
495 | export function useSendFeedback() {
496 |   const client = useClient();
497 |   return (opts: UnassignedFeedback) => client.feedback(opts);
498 | }
499 | 
500 | /**
501 |  * Returns a function to update the current user's information.
502 |  * For example, if the user changed role or opted into a beta-feature.
503 |  *
504 |  * The method returned is a function which returns a promise that
505 |  * resolves when after the features have been updated as a result
506 |  * of the user update.
507 |  *
508 |  * ```ts
509 |  * const updateUser = useUpdateUser();
510 |  * updateUser({ optInHuddles: "true" }).then(() => console.log("Flags updated"));
511 |  * ```
512 |  */
513 | export function useUpdateUser() {
514 |   const client = useClient();
515 |   return (opts: { [key: string]: string | number | undefined }) =>
516 |     client.updateUser(opts);
517 | }
518 | 
519 | /**
520 |  * Returns a function to update the current company's information.
521 |  * For example, if the company changed plan or opted into a beta-feature.
522 |  *
523 |  * The method returned is a function which returns a promise that
524 |  * resolves when after the features have been updated as a result
525 |  * of the company update.
526 |  *
527 |  * ```ts
528 |  * const updateCompany = useUpdateCompany();
529 |  * updateCompany({ plan: "enterprise" }).then(() => console.log("Flags updated"));
530 |  * ```
531 |  */
532 | export function useUpdateCompany() {
533 |   const client = useClient();
534 | 
535 |   return (opts: { [key: string]: string | number | undefined }) =>
536 |     client.updateCompany(opts);
537 | }
538 | 
539 | /**
540 |  * Returns a function to update the "other" context information.
541 |  * For example, if the user changed workspace, you can set the workspace id here.
542 |  *
543 |  * The method returned is a function which returns a promise that
544 |  * resolves when after the features have been updated as a result
545 |  * of the update to the "other" context.
546 |  *
547 |  * ```ts
548 |  * const updateOtherContext = useUpdateOtherContext();
549 |  * updateOtherContext({ workspaceId: newWorkspaceId })
550 |  *   .then(() => console.log("Flags updated"));
551 |  * ```
552 |  */
553 | export function useUpdateOtherContext() {
554 |   const client = useClient();
555 |   return (opts: { [key: string]: string | number | undefined }) =>
556 |     client.updateOtherContext(opts);
557 | }
558 | 
559 | /**
560 |  * Returns the current `ReflagProvider` context.
561 |  * @internal
562 |  */
563 | function useSafeContext() {
564 |   const ctx = useContext(ProviderContext);
565 |   if (!ctx) {
566 |     throw new Error(
567 |       `ReflagProvider is missing. Please ensure your component is wrapped with a ReflagProvider.`,
568 |     );
569 |   }
570 |   return ctx;
571 | }
572 | 
573 | /**
574 |  * Returns a boolean indicating if the Reflag client is loading.
575 |  * You can use this to check if the Reflag client is loading at any point in your application.
576 |  * Initially, the value will be true until the client is initialized.
577 |  *
578 |  * @example
579 |  * ```ts
580 |  * import { useIsLoading } from '@reflag/react-sdk';
581 |  *
582 |  * const isLoading = useIsLoading();
583 |  *
584 |  * console.log(isLoading);
585 |  * ```
586 |  *
587 |  * @returns A boolean indicating if the Reflag client is loading.
588 |  */
589 | export function useIsLoading() {
590 |   const context = useSafeContext();
591 |   return context.isLoading;
592 | }
593 | 
594 | /**
595 |  * Returns the current `ReflagClient` used by the `ReflagProvider`.
596 |  *
597 |  * This is useful if you need to access the `ReflagClient` outside of the `ReflagProvider`.
598 |  *
599 |  * @example
600 |  * ```ts
601 |  * import { useClient } from '@reflag/react-sdk';
602 |  *
603 |  * function App() {
604 |  *   const client = useClient();
605 |  *   console.log(client.getContext());
606 |  * }
607 |  * ```
608 |  *
609 |  * @returns The `ReflagClient`.
610 |  */
611 | export function useClient() {
612 |   const context = useSafeContext();
613 |   return context.client;
614 | }
615 | 
616 | /**
617 |  * Attach a callback handler to client events to act on changes. It automatically disposes itself on unmount.
618 |  *
619 |  * @example
620 |  * ```ts
621 |  * import { useOnEvent } from '@reflag/react-sdk';
622 |  *
623 |  * useOnEvent("flagsUpdated", () => {
624 |  *   console.log("flags updated");
625 |  * });
626 |  * ```
627 |  *
628 |  * @param event - The event to listen to.
629 |  * @param handler - The function to call when the event is triggered.
630 |  * @param client - The Reflag client to listen to. If not provided, the client will be retrieved from the context.
631 |  */
632 | export function useOnEvent<THookType extends keyof HookArgs>(
633 |   event: THookType,
634 |   handler: (arg0: HookArgs[THookType]) => void,
635 |   client?: ReflagClient,
636 | ) {
637 |   const contextClient = useContext(ProviderContext);
638 |   const resolvedClient = client ?? contextClient?.client;
639 |   if (!resolvedClient) {
640 |     throw new Error(
641 |       `ReflagProvider is missing and no client was provided. Please ensure your component is wrapped with a ReflagProvider.`,
642 |     );
643 |   }
644 |   useEffect(() => {
645 |     return resolvedClient.on(event, handler);
646 |   }, [resolvedClient, event, handler]);
647 | }
648 | 
```

--------------------------------------------------------------------------------
/packages/node-sdk/src/types.ts:
--------------------------------------------------------------------------------

```typescript
  1 | /* eslint-disable @typescript-eslint/no-empty-object-type */
  2 | 
  3 | import { newEvaluator, RuleFilter } from "@reflag/flag-evaluation";
  4 | 
  5 | /**
  6 |  * Describes the meta context associated with tracking.
  7 |  **/
  8 | export type TrackingMeta = {
  9 |   /**
 10 |    * Whether the user or company is active.
 11 |    **/
 12 |   active?: boolean;
 13 | };
 14 | 
 15 | /**
 16 |  * Describes the attributes of a user, company or event.
 17 |  **/
 18 | export type Attributes = Record<string, any>;
 19 | 
 20 | /**
 21 |  * Describes a flag event. Can be "check" or "check-config event".
 22 |  **/
 23 | export type FlagEvent = {
 24 |   /**
 25 |    * The action that was performed.
 26 |    **/
 27 |   action: "check" | "check-config";
 28 | 
 29 |   /**
 30 |    * The flag key.
 31 |    **/
 32 |   key: string;
 33 | 
 34 |   /**
 35 |    * The feature targeting version (optional).
 36 |    **/
 37 |   targetingVersion: number | undefined;
 38 | 
 39 |   /**
 40 |    * The result of targeting evaluation.
 41 |    **/
 42 |   evalResult:
 43 |     | boolean
 44 |     | { key: string; payload: any }
 45 |     | { key: undefined; payload: undefined };
 46 | 
 47 |   /**
 48 |    * The context that was used for evaluation.
 49 |    **/
 50 |   evalContext?: Record<string, any>;
 51 | 
 52 |   /**
 53 |    * The result of evaluation of each rule (optional).
 54 |    **/
 55 |   evalRuleResults?: boolean[];
 56 | 
 57 |   /**
 58 |    * The missing fields in the evaluation context (optional).
 59 |    **/
 60 |   evalMissingFields?: string[];
 61 | };
 62 | 
 63 | /**
 64 |  * A remotely managed configuration value for a feature.
 65 |  */
 66 | export type RawFlagRemoteConfig = {
 67 |   /**
 68 |    * The key of the matched configuration value.
 69 |    */
 70 |   key: string;
 71 | 
 72 |   /**
 73 |    * The version of the targeting rules used to select the config value.
 74 |    */
 75 |   targetingVersion?: number;
 76 | 
 77 |   /**
 78 |    * The optional user-supplied payload data.
 79 |    */
 80 |   payload: any;
 81 | 
 82 |   /**
 83 |    * The rule results of the evaluation (optional).
 84 |    */
 85 |   ruleEvaluationResults?: boolean[];
 86 | 
 87 |   /**
 88 |    * The missing fields in the evaluation context (optional).
 89 |    */
 90 |   missingContextFields?: string[];
 91 | };
 92 | 
 93 | /**
 94 |  * Describes a feature.
 95 |  */
 96 | export interface RawFlag {
 97 |   /**
 98 |    * The key of the feature.
 99 |    */
100 |   key: string;
101 | 
102 |   /**
103 |    * If the feature is enabled.
104 |    */
105 |   isEnabled: boolean;
106 | 
107 |   /**
108 |    * The version of the targeting used to evaluate if the feature is enabled (optional).
109 |    */
110 |   targetingVersion?: number;
111 | 
112 |   /**
113 |    * The remote configuration value for the feature.
114 |    */
115 |   config?: RawFlagRemoteConfig;
116 | 
117 |   /**
118 |    * The rule results of the evaluation (optional).
119 |    */
120 |   ruleEvaluationResults?: boolean[];
121 | 
122 |   /**
123 |    * The missing fields in the evaluation context (optional).
124 |    */
125 |   missingContextFields?: string[];
126 | }
127 | 
128 | /**
129 |  * Describes a collection of evaluated raw flags.
130 |  */
131 | export type RawFlags = Record<TypedFlagKey, RawFlag>;
132 | 
133 | /**
134 |  * Describes a collection of evaluated raw flags and the context for bootstrapping.
135 |  */
136 | export type BootstrappedFlags = {
137 |   context: Context;
138 |   flags: RawFlags;
139 | };
140 | 
141 | export type EmptyFlagRemoteConfig = { key: undefined; payload: undefined };
142 | 
143 | /**
144 |  * A remotely managed configuration value for a feature.
145 |  */
146 | export type FlagRemoteConfig =
147 |   | {
148 |       /**
149 |        * The key of the matched configuration value.
150 |        */
151 |       key: string;
152 | 
153 |       /**
154 |        * The optional user-supplied payload data.
155 |        */
156 |       payload: any;
157 |     }
158 |   | EmptyFlagRemoteConfig;
159 | 
160 | /**
161 |  * Describes a feature
162 |  */
163 | export interface Flag<
164 |   TConfig extends FlagType["config"] = EmptyFlagRemoteConfig,
165 | > {
166 |   /**
167 |    * The key of the feature.
168 |    */
169 |   key: string;
170 | 
171 |   /**
172 |    * If the feature is enabled.
173 |    */
174 |   isEnabled: boolean;
175 | 
176 |   /*
177 |    * Optional user-defined configuration.
178 |    */
179 |   config:
180 |     | ({
181 |         key: string;
182 |       } & TConfig)
183 |     | EmptyFlagRemoteConfig;
184 | 
185 |   /**
186 |    * Track feature usage in Reflag.
187 |    */
188 |   track(): Promise<void>;
189 | }
190 | 
191 | export type FlagType = {
192 |   config?: {
193 |     payload: any;
194 |   };
195 | };
196 | 
197 | export type FlagOverride =
198 |   | (FlagType & {
199 |       isEnabled: boolean;
200 |       config?: {
201 |         key: string;
202 |       };
203 |     })
204 |   | boolean;
205 | 
206 | /**
207 |  * Describes a feature definition.
208 |  */
209 | export type FlagDefinition = {
210 |   /**
211 |    * The key of the feature.
212 |    */
213 |   key: string;
214 | 
215 |   /**
216 |    * Description of the feature.
217 |    */
218 |   description: string | null;
219 | 
220 |   /**
221 |    * The targeting rules for the feature.
222 |    */
223 |   flag: {
224 |     /**
225 |      * The version of the targeting rules.
226 |      */
227 |     version: number;
228 | 
229 |     /**
230 |      * The targeting rules.
231 |      */
232 |     rules: {
233 |       /**
234 |        * The filter for the rule.
235 |        */
236 |       filter: RuleFilter;
237 |     }[];
238 |   };
239 | 
240 |   /**
241 |    * The remote configuration for the feature.
242 |    */
243 |   config?: {
244 |     /**
245 |      * The version of the remote configuration.
246 |      */
247 |     version: number;
248 | 
249 |     /**
250 |      * The variants of the remote configuration.
251 |      */
252 |     variants: FlagConfigVariant[];
253 |   };
254 | };
255 | 
256 | /**
257 |  * Describes a collection of evaluated features.
258 |  *
259 |  * @remarks
260 |  * You should extend the Flags interface to define the available features.
261 |  */
262 | export interface Flags {}
263 | 
264 | /**
265 |  * Describes a collection of evaluated feature.
266 |  *
267 |  * @remarks
268 |  * This types falls back to a generic Record<string, Flag> if the Flags interface
269 |  * has not been extended.
270 |  *
271 |  */
272 | export type TypedFlags = keyof Flags extends never
273 |   ? Record<string, Flag>
274 |   : {
275 |       [FlagKey in keyof Flags]: Flags[FlagKey] extends FlagType
276 |         ? Flag<Flags[FlagKey]["config"]>
277 |         : Flag;
278 |     };
279 | 
280 | export type TypedFlagKey = keyof TypedFlags;
281 | 
282 | /**
283 |  * Describes the feature overrides.
284 |  */
285 | export type FlagOverrides = Partial<
286 |   keyof Flags extends never
287 |     ? Record<string, FlagOverride>
288 |     : {
289 |         [FlagKey in keyof Flags]: Flags[FlagKey] extends FlagOverride
290 |           ? Flags[FlagKey]
291 |           : Exclude<FlagOverride, "config">;
292 |       }
293 | >;
294 | 
295 | export type FlagOverridesFn = (context: Context) => FlagOverrides;
296 | 
297 | /**
298 |  * Describes a remote feature config variant.
299 |  */
300 | export type FlagConfigVariant = {
301 |   /**
302 |    * The filter for the variant.
303 |    */
304 |   filter: RuleFilter;
305 | 
306 |   /**
307 |    * The optional user-supplied payload data.
308 |    */
309 |   payload: any;
310 | 
311 |   /**
312 |    * The key of the variant.
313 |    */
314 |   key: string;
315 | };
316 | 
317 | /**
318 |  * (Internal) Describes a specific feature in the API response.
319 |  *
320 |  * @internal
321 |  */
322 | export type FlagAPIResponse = {
323 |   /**
324 |    * The key of the feature.
325 |    */
326 |   key: string;
327 | 
328 |   /**
329 |    * Description of the feature.
330 |    */
331 |   description: string | null;
332 | 
333 |   /**
334 |    * The targeting rules for the feature.
335 |    */
336 |   targeting: {
337 |     /**
338 |      * The version of the targeting rules.
339 |      */
340 |     version: number;
341 | 
342 |     /**
343 |      * The targeting rules.
344 |      */
345 |     rules: {
346 |       /**
347 |        * The filter for the rule.
348 |        */
349 |       filter: RuleFilter;
350 |     }[];
351 |   };
352 | 
353 |   /**
354 |    * The remote configuration for the feature.
355 |    */
356 |   config?: {
357 |     /**
358 |      * The version of the remote configuration.
359 |      */
360 |     version: number;
361 | 
362 |     /**
363 |      * The variants of the remote configuration.
364 |      */
365 |     variants: FlagConfigVariant[];
366 |   };
367 | };
368 | 
369 | /**
370 |  * (Internal) Describes the response of the features endpoint.
371 |  *
372 |  * @internal
373 |  */
374 | export type FlagsAPIResponse = {
375 |   /**
376 |    * The feature definitions.
377 |    */
378 |   features: FlagAPIResponse[];
379 | };
380 | 
381 | /**
382 |  * (Internal) Flag definitions with the addition of a pre-prepared
383 |  * evaluators functions for the rules.
384 |  *
385 |  * @internal
386 |  */
387 | export type CachedFlagDefinition = FlagAPIResponse & {
388 |   enabledEvaluator: ReturnType<typeof newEvaluator<boolean>>;
389 |   configEvaluator: ReturnType<typeof newEvaluator<any>> | undefined;
390 | };
391 | 
392 | /**
393 |  * (Internal) Describes the response of the evaluated features endpoint.
394 |  *
395 |  * @internal
396 |  */
397 | export type EvaluatedFlagsAPIResponse = {
398 |   /**
399 |    * True if request successful.
400 |    */
401 |   success: true;
402 | 
403 |   /**
404 |    * True if additional context for user or company was found and used for evaluation on the remote server.
405 |    */
406 |   remoteContextUsed: boolean;
407 | 
408 |   /**
409 |    * The feature definitions.
410 |    */
411 |   features: RawFlags;
412 | };
413 | 
414 | /**
415 |  * Describes the response of a HTTP client.
416 |  *
417 |  * @typeParam TResponse - The type of the response body.
418 |  */
419 | export type HttpClientResponse<TResponse> = {
420 |   /**
421 |    * The status code of the response.
422 |    **/
423 |   status: number;
424 | 
425 |   /**
426 |    * Indicates that the request succeeded.
427 |    **/
428 |   ok: boolean;
429 | 
430 |   /**
431 |    * The body of the response if available.
432 |    **/
433 |   body: TResponse | undefined;
434 | };
435 | 
436 | /**
437 |  * Defines the interface for an HTTP client.
438 |  *
439 |  * @remarks
440 |  * This interface is used to abstract the HTTP client implementation from the SDK.
441 |  * Define your own implementation of this interface to use a different HTTP client.
442 |  **/
443 | export interface HttpClient {
444 |   /**
445 |    * Sends a POST request to the specified URL.
446 |    *
447 |    * @param url - The URL to send the request to.
448 |    * @param headers - The headers to include in the request.
449 |    * @param body - The body of the request.
450 |    * @returns The response from the server.
451 |    **/
452 |   post<TBody, TResponse>(
453 |     url: string,
454 |     headers: Record<string, string>,
455 |     body: TBody,
456 |   ): Promise<HttpClientResponse<TResponse>>;
457 | 
458 |   /**
459 |    * Sends a GET request to the specified URL.
460 |    *
461 |    * @param url - The URL to send the request to.
462 |    * @param headers - The headers to include in the request.
463 |    * @returns The response from the server.
464 |    **/
465 |   get<TResponse>(
466 |     url: string,
467 |     headers: Record<string, string>,
468 |     timeoutMs: number,
469 |   ): Promise<HttpClientResponse<TResponse>>;
470 | }
471 | 
472 | /**
473 |  * Logger interface for logging messages
474 |  */
475 | export interface Logger {
476 |   /**
477 |    * Log a debug messages
478 |    *
479 |    * @param message - The message to log
480 |    * @param data - Optional data to log
481 |    */
482 |   debug: (message: string, data?: any) => void;
483 | 
484 |   /**
485 |    * Log an info messages
486 |    *
487 |    * @param message - The message to log
488 |    * @param data - Optional data to log
489 |    */
490 |   info: (message: string, data?: any) => void;
491 | 
492 |   /**
493 |    * Log a warning messages
494 |    *
495 |    * @param message - The message to log
496 |    * @param data - Optional data to log
497 |    */
498 |   warn: (message: string, data?: any) => void;
499 | 
500 |   /**
501 |    * Log an error messages
502 |    *
503 |    * @param message - The message to log
504 |    * @param data - Optional data to log
505 |    */
506 |   error: (message: string, data?: any) => void;
507 | }
508 | 
509 | /**
510 |  * A cache for storing values.
511 |  *
512 |  * @typeParam T - The type of the value.
513 |  **/
514 | export type Cache<T> = {
515 |   /**
516 |    * Get the value.
517 |    * @returns The value or `undefined` if the value is not available.
518 |    **/
519 |   get: () => T | undefined;
520 | 
521 |   /**
522 |    * Refresh the value immediately and return it, or `undefined` if the value is not available.
523 |    *
524 |    * @returns The value or `undefined` if the value is not available.
525 |    **/
526 |   refresh: () => Promise<T | undefined>;
527 | 
528 |   /**
529 |    * If a refresh is in progress, wait for it to complete.
530 |    *
531 |    * @returns A promise that resolves when the refresh is complete.
532 |    **/
533 |   waitRefresh: () => Promise<void> | undefined;
534 | 
535 |   /**
536 |    * Cleanup and destroy the cache, stopping any background processes.
537 |    **/
538 |   destroy: () => void;
539 | };
540 | 
541 | /**
542 |  * Options for configuring the BatchBuffer.
543 |  *
544 |  * @template T - The type of items in the buffer.
545 |  */
546 | export type BatchBufferOptions<T> = {
547 |   /**
548 |    * A function that handles flushing the items in the buffer.
549 |    **/
550 |   flushHandler: (items: T[]) => Promise<void>;
551 | 
552 |   /**
553 |    * The logger to use for logging (optional).
554 |    **/
555 |   logger?: Logger;
556 | 
557 |   /**
558 |    * The maximum size of the buffer before it is flushed.
559 |    *
560 |    * @defaultValue `100`
561 |    **/
562 |   maxSize?: number;
563 | 
564 |   /**
565 |    * The interval in milliseconds at which the buffer is flushed.
566 |    * @remarks
567 |    * If `0`, the buffer is flushed only when `maxSize` is reached.
568 |    * @defaultValue `1000`
569 |    **/
570 |   intervalMs?: number;
571 | 
572 |   /**
573 |    * Whether to flush the buffer on exit.
574 |    *
575 |    * @defaultValue `true`
576 |    */
577 |   flushOnExit?: boolean;
578 | };
579 | 
580 | export type CacheStrategy = "periodically-update" | "in-request";
581 | 
582 | /**
583 |  * Defines the options for the SDK client.
584 |  *
585 |  **/
586 | export type ClientOptions = {
587 |   /**
588 |    * The secret key used to authenticate with the Reflag API.
589 |    **/
590 |   secretKey?: string;
591 | 
592 |   /**
593 |    * @deprecated
594 |    * Use `apiBaseUrl` instead.
595 |    **/
596 |   host?: string;
597 | 
598 |   /**
599 |    * The host to send requests to (optional).
600 |    **/
601 |   apiBaseUrl?: string;
602 | 
603 |   /**
604 |    * The logger to use for logging (optional). Default is info level logging to console.
605 |    **/
606 |   logger?: Logger;
607 | 
608 |   /**
609 |    * Use the console logger, but set a log level. Ineffective if a custom logger is provided.
610 |    **/
611 |   logLevel?: LogLevel;
612 | 
613 |   /**
614 |    * The features to "enable" as fallbacks when the API is unavailable (optional).
615 |    * Can be an array of feature keys, or a record of feature keys and boolean or object values.
616 |    *
617 |    * If a record is supplied instead of array, the values of each key are either the
618 |    * configuration values or the boolean value `true`.
619 |    **/
620 |   fallbackFlags?:
621 |     | TypedFlagKey[]
622 |     | Record<TypedFlagKey, Exclude<FlagOverride, false>>;
623 | 
624 |   /**
625 |    * The HTTP client to use for sending requests (optional). Default is the built-in fetch client.
626 |    **/
627 |   httpClient?: HttpClient;
628 | 
629 |   /**
630 |    * The timeout in milliseconds for fetching feature targeting data (optional).
631 |    * Default is 10000 ms.
632 |    **/
633 |   fetchTimeoutMs?: number;
634 | 
635 |   /**
636 |    * Number of times to retry fetching feature definitions (optional).
637 |    * Default is 3 times.
638 |    **/
639 |   flagsFetchRetries?: number;
640 | 
641 |   /**
642 |    * The options for the batch buffer (optional).
643 |    * If not provided, the default options are used.
644 |    **/
645 |   batchOptions?: Omit<BatchBufferOptions<any>, "flushHandler" | "logger">;
646 | 
647 |   /**
648 |    * If a filename is specified, feature targeting results be overridden with
649 |    * the values from this file. The file should be a JSON object with flag
650 |    * keys as keys, and boolean or object as values.
651 |    *
652 |    * If a function is specified, the function will be called with the context
653 |    * and should return a record of flag keys and boolean or object values.
654 |    *
655 |    * Defaults to "reflagFlags.json".
656 |    **/
657 |   flagOverrides?: string | ((context: Context) => FlagOverrides);
658 | 
659 |   /**
660 |    * In offline mode, no data is sent or fetched from the the Reflag API.
661 |    * This is useful for testing or development.
662 |    */
663 |   offline?: boolean;
664 | 
665 |   /**
666 |    * If set to `false`, no evaluation events will be emitted.
667 |    */
668 |   emitEvaluationEvents?: boolean;
669 | 
670 |   /**
671 |    * The path to the config file. If supplied, the config file will be loaded.
672 |    * Defaults to `reflag.config.json` when NODE_ENV is not production. Can also be
673 |    * set through the environment variable REFLAG_CONFIG_FILE.
674 |    */
675 |   configFile?: string;
676 | 
677 |   /**
678 |    * The cache strategy to use for the client (optional, defaults to "periodically-update").
679 |    **/
680 |   cacheStrategy?: CacheStrategy;
681 | };
682 | 
683 | /**
684 |  * Defines the options for tracking of entities.
685 |  *
686 |  **/
687 | export type TrackOptions = {
688 |   /**
689 |    * The attributes associated with the event.
690 |    **/
691 |   attributes?: Attributes;
692 | 
693 |   /**
694 |    * The meta context associated with the event.
695 |    **/
696 |   meta?: TrackingMeta;
697 | };
698 | 
699 | /**
700 |  * Describes the current user context, company context, and other context.
701 |  * This is used to determine if feature targeting matches and to track events.
702 |  **/
703 | export type Context = {
704 |   /**
705 |    * The user context. If no `id` key is set, the whole object is ignored.
706 |    */
707 |   user?: {
708 |     /**
709 |      * The identifier of the user.
710 |      */
711 |     id: string | number | undefined;
712 | 
713 |     /**
714 |      * The name of the user.
715 |      */
716 |     name?: string | undefined;
717 | 
718 |     /**
719 |      * The email of the user.
720 |      */
721 |     email?: string | undefined;
722 | 
723 |     /**
724 |      * The avatar URL of the user.
725 |      */
726 |     avatar?: string | undefined;
727 | 
728 |     /**
729 |      * Custom attributes of the user.
730 |      */
731 |     [k: string]: any;
732 |   };
733 |   /**
734 |    * The company context. If no `id` key is set, the whole object is ignored.
735 |    */
736 |   company?: {
737 |     /**
738 |      * The identifier of the company.
739 |      */
740 |     id: string | number | undefined;
741 | 
742 |     /**
743 |      * The name of the company.
744 |      */
745 |     name?: string | undefined;
746 | 
747 |     /**
748 |      * The avatar URL of the company.
749 |      */
750 |     avatar?: string | undefined;
751 | 
752 |     /**
753 |      * Custom attributes of the company.
754 |      */
755 |     [k: string]: any;
756 |   };
757 | 
758 |   /**
759 |    * The other context. This is used for any additional context that is not related to user or company.
760 |    */
761 |   other?: Record<string, any>;
762 | };
763 | 
764 | /**
765 |  * A context with tracking option.
766 |  **/
767 | export interface ContextWithTracking extends Context {
768 |   /**
769 |    * Enable tracking for the context.
770 |    * If set to `false`, tracking will be disabled for the context. Default is `true`.
771 |    */
772 |   enableTracking?: boolean;
773 | 
774 |   /**
775 |    * The meta context used to update the user or company when syncing is required during
776 |    * feature retrieval.
777 |    */
778 |   meta?: TrackingMeta;
779 | }
780 | 
781 | export const LOG_LEVELS = ["DEBUG", "INFO", "WARN", "ERROR"] as const;
782 | export type LogLevel = (typeof LOG_LEVELS)[number];
783 | 
784 | export type IdType = string | number;
785 | 
```

--------------------------------------------------------------------------------
/packages/browser-sdk/test/usage.test.ts:
--------------------------------------------------------------------------------

```typescript
  1 | import { http, HttpResponse } from "msw";
  2 | import {
  3 |   afterEach,
  4 |   beforeAll,
  5 |   beforeEach,
  6 |   describe,
  7 |   expect,
  8 |   it,
  9 |   test,
 10 |   vi,
 11 | } from "vitest";
 12 | 
 13 | import { ReflagClient } from "../src";
 14 | import { API_BASE_URL } from "../src/config";
 15 | import { FeedbackPromptHandler } from "../src/feedback/feedback";
 16 | import {
 17 |   checkPromptMessageCompleted,
 18 |   getAuthToken,
 19 |   markPromptMessageCompleted,
 20 | } from "../src/feedback/promptStorage";
 21 | import { FlagsClient } from "../src/flag/flags";
 22 | import { HttpClient } from "../src/httpClient";
 23 | import {
 24 |   AblySSEChannel,
 25 |   closeAblySSEChannel,
 26 |   openAblySSEChannel,
 27 | } from "../src/sse";
 28 | 
 29 | import { flagsResult } from "./mocks/handlers";
 30 | import { server } from "./mocks/server";
 31 | 
 32 | const KEY = "123";
 33 | 
 34 | vi.mock("../src/sse");
 35 | vi.mock("../src/feedback/promptStorage", () => {
 36 |   return {
 37 |     markPromptMessageCompleted: vi.fn(),
 38 |     checkPromptMessageCompleted: vi.fn(),
 39 |     rememberAuthToken: vi.fn(),
 40 |     getAuthToken: vi.fn(),
 41 |   };
 42 | });
 43 | 
 44 | // Treat test environment as desktop
 45 | window.innerWidth = 1024;
 46 | 
 47 | afterEach(() => {
 48 |   server.resetHandlers();
 49 | });
 50 | 
 51 | describe("usage", () => {
 52 |   afterEach(() => {
 53 |     vi.clearAllMocks();
 54 |   });
 55 | 
 56 |   test("golden path - register `user`, `company`, send `event`, send `feedback`, get `flags`", async () => {
 57 |     const reflagInstance = new ReflagClient({
 58 |       publishableKey: KEY,
 59 |       user: { id: "foo " },
 60 |       company: { id: "bar", name: "bar corp" },
 61 |     });
 62 |     await reflagInstance.initialize();
 63 | 
 64 |     await reflagInstance.track("baz", { baz: true });
 65 | 
 66 |     await reflagInstance.feedback({
 67 |       flagKey: "huddles",
 68 |       score: 5,
 69 |       comment: "Sunt bine!",
 70 |       question: "Cum esti?",
 71 |       promptedQuestion: "How are you?",
 72 |     });
 73 | 
 74 |     const flags = reflagInstance.getFlags();
 75 |     expect(flags).toEqual(flagsResult);
 76 | 
 77 |     const flag = reflagInstance.getFlag("flag-1");
 78 |     expect(flag).toStrictEqual({
 79 |       isEnabled: false,
 80 |       track: expect.any(Function),
 81 |       requestFeedback: expect.any(Function),
 82 |       config: { key: undefined, payload: undefined },
 83 |       isEnabledOverride: null,
 84 |       setIsEnabledOverride: expect.any(Function),
 85 |     });
 86 |   });
 87 | 
 88 |   test("accepts `flagKey` instead of `featureId` for manual feedback", async () => {
 89 |     const reflagInstance = new ReflagClient({
 90 |       publishableKey: KEY,
 91 |       user: { id: "foo" },
 92 |       company: { id: "bar" },
 93 |     });
 94 | 
 95 |     await reflagInstance.initialize();
 96 | 
 97 |     await reflagInstance.feedback({
 98 |       flagKey: "flag-key",
 99 |       score: 5,
100 |       question: "What's up?",
101 |       promptedQuestion: "How are you?",
102 |     });
103 |   });
104 | });
105 | 
106 | // TODO:
107 | // Since we now have AutoFeedback as it's own class, we should rewrite these tests
108 | // to test that class instead of the ReflagClient class.
109 | // Same for feedback state management below
110 | 
111 | describe("feedback prompting", () => {
112 |   const closeChannel = vi.fn();
113 |   beforeAll(() => {
114 |     vi.mocked(openAblySSEChannel).mockReturnValue({
115 |       close: closeChannel,
116 |     } as unknown as AblySSEChannel);
117 |     vi.mocked(closeAblySSEChannel).mockResolvedValue(undefined);
118 |   });
119 | 
120 |   afterEach(() => {
121 |     vi.clearAllMocks();
122 |     vi.mocked(getAuthToken).mockReturnValue(undefined);
123 |   });
124 | 
125 |   test("initiates and stops feedback prompting", async () => {
126 |     const reflagInstance = new ReflagClient({
127 |       publishableKey: KEY,
128 |       user: { id: "foo" },
129 |     });
130 |     await reflagInstance.initialize();
131 | 
132 |     expect(openAblySSEChannel).toBeCalledTimes(1);
133 | 
134 |     // call twice, expect only one reset to go through
135 |     await reflagInstance.stop();
136 |     await reflagInstance.stop();
137 | 
138 |     expect(closeChannel).toBeCalledTimes(1);
139 |   });
140 | 
141 |   test("does not call tracking endpoints if token cached", async () => {
142 |     const specialChannel = "special-channel";
143 |     vi.mocked(getAuthToken).mockReturnValue({
144 |       channel: specialChannel,
145 |       token: "something",
146 |     });
147 | 
148 |     server.use(
149 |       http.post(`${API_BASE_URL}/feedback/prompting-init`, () => {
150 |         throw new Error("should not be called");
151 |       }),
152 |     );
153 | 
154 |     const reflagInstance = new ReflagClient({
155 |       publishableKey: KEY,
156 |       user: { id: "foo" },
157 |     });
158 |     await reflagInstance.initialize();
159 | 
160 |     expect(openAblySSEChannel).toBeCalledTimes(1);
161 |     const args = vi.mocked(openAblySSEChannel).mock.calls[0][0];
162 |     expect(args.channel).toBe(specialChannel);
163 |     expect(args.userId).toBe("foo");
164 |   });
165 | 
166 |   test("does not initiate feedback prompting if server does not agree", async () => {
167 |     server.use(
168 |       http.post(`${API_BASE_URL}/feedback/prompting-init`, () => {
169 |         return HttpResponse.json({ success: false });
170 |       }),
171 |     );
172 | 
173 |     const reflagInstance = new ReflagClient({
174 |       publishableKey: KEY,
175 |       user: { id: "foo" },
176 |     });
177 |     await reflagInstance.initialize();
178 | 
179 |     expect(openAblySSEChannel).toBeCalledTimes(0);
180 |   });
181 | 
182 |   test("skip feedback prompting if no user id configured", async () => {
183 |     const reflagInstance = new ReflagClient({ publishableKey: KEY });
184 |     await reflagInstance.initialize();
185 | 
186 |     expect(openAblySSEChannel).toBeCalledTimes(0);
187 |   });
188 | 
189 |   test("skip feedback prompting if automated feedback surveys are disabled", async () => {
190 |     const reflagInstance = new ReflagClient({
191 |       publishableKey: KEY,
192 |       user: { id: "foo" },
193 |       feedback: { enableAutoFeedback: false },
194 |     });
195 |     await reflagInstance.initialize();
196 | 
197 |     expect(openAblySSEChannel).toBeCalledTimes(0);
198 |   });
199 | });
200 | 
201 | describe("feedback state management", () => {
202 |   const message = {
203 |     question: "How are you?",
204 |     showAfter: new Date(Date.now() - 10000).valueOf(),
205 |     showBefore: new Date(Date.now() + 10000).valueOf(),
206 |     promptId: "123",
207 |     featureId: "456",
208 |   };
209 | 
210 |   let events: string[] = [];
211 |   let reflagInstance: ReflagClient | null = null;
212 |   beforeEach(() => {
213 |     vi.mocked(openAblySSEChannel).mockImplementation(({ callback }) => {
214 |       callback(message);
215 |       // eslint-disable-next-line @typescript-eslint/no-empty-function
216 |       return { close: () => {} } as AblySSEChannel;
217 |     });
218 |     events = [];
219 |     server.use(
220 |       http.post(
221 |         `${API_BASE_URL}/feedback/prompt-events`,
222 |         async ({ request }) => {
223 |           const body = await request.json();
224 |           if (!(body && typeof body === "object" && "action" in body)) {
225 |             throw new Error("invalid request");
226 |           }
227 |           events.push(String(body["action"]));
228 |           return HttpResponse.json({ success: true });
229 |         },
230 |       ),
231 |     );
232 |   });
233 | 
234 |   afterEach(async () => {
235 |     if (reflagInstance) await reflagInstance.stop();
236 | 
237 |     vi.resetAllMocks();
238 |   });
239 | 
240 |   const createReflagInstance = async (callback: FeedbackPromptHandler) => {
241 |     reflagInstance = new ReflagClient({
242 |       publishableKey: KEY,
243 |       user: { id: "foo" },
244 |       feedback: {
245 |         autoFeedbackHandler: callback,
246 |       },
247 |     });
248 |     await reflagInstance.initialize();
249 |     return reflagInstance;
250 |   };
251 | 
252 |   test("ignores prompt if expired", async () => {
253 |     vi.useFakeTimers();
254 |     vi.setSystemTime(message.showAfter - 10000);
255 | 
256 |     const callback = vi.fn();
257 | 
258 |     await createReflagInstance(callback);
259 | 
260 |     expect(callback).not.toHaveBeenCalled();
261 | 
262 |     expect(markPromptMessageCompleted).not.toHaveBeenCalledOnce();
263 | 
264 |     vi.clearAllTimers();
265 |     vi.useRealTimers();
266 |   });
267 | 
268 |   test("ignores prompt if already seen", async () => {
269 |     vi.mocked(checkPromptMessageCompleted).mockReturnValue(true);
270 |     expect(checkPromptMessageCompleted).not.toHaveBeenCalled();
271 | 
272 |     const callback = vi.fn();
273 | 
274 |     await createReflagInstance(callback);
275 | 
276 |     expect(callback).not.toHaveBeenCalled();
277 |     await vi.waitFor(() =>
278 |       expect(checkPromptMessageCompleted).toHaveBeenCalledOnce(),
279 |     );
280 | 
281 |     expect(checkPromptMessageCompleted).toHaveBeenCalledWith("foo", "123");
282 |   });
283 | 
284 |   test("propagates prompt to the callback", async () => {
285 |     const callback = vi.fn();
286 | 
287 |     await createReflagInstance(callback);
288 |     await vi.waitUntil(() => callback.mock.calls.length > 0);
289 | 
290 |     await vi.waitUntil(() => events.length > 1);
291 | 
292 |     expect(events).toEqual(["received", "shown"]);
293 | 
294 |     expect(callback).toBeCalledTimes(1);
295 |     expect(callback).toBeCalledWith(
296 |       {
297 |         question: "How are you?",
298 |         showAfter: new Date(message.showAfter),
299 |         showBefore: new Date(message.showBefore),
300 |         promptId: "123",
301 |         featureId: "456",
302 |       },
303 |       expect.anything(),
304 |     );
305 | 
306 |     expect(markPromptMessageCompleted).not.toHaveBeenCalled();
307 |   });
308 | 
309 |   test("propagates timed prompt to the callback", async () => {
310 |     const callback = vi.fn();
311 | 
312 |     vi.useFakeTimers();
313 |     vi.setSystemTime(message.showAfter - 500);
314 | 
315 |     await createReflagInstance(callback);
316 | 
317 |     expect(callback).not.toBeCalled();
318 | 
319 |     vi.runAllTimers();
320 |     await vi.waitUntil(() => callback.mock.calls.length > 0);
321 | 
322 |     await vi.waitUntil(() => events.length > 1);
323 | 
324 |     expect(events).toEqual(["received", "shown"]);
325 | 
326 |     expect(callback).toBeCalledTimes(1);
327 | 
328 |     expect(markPromptMessageCompleted).not.toHaveBeenCalled();
329 | 
330 |     vi.clearAllTimers();
331 |     vi.useRealTimers();
332 |   });
333 | 
334 |   test("propagates prompt to the callback and reacts to dismissal", async () => {
335 |     const callback: FeedbackPromptHandler = async (_, handlers) => {
336 |       await handlers.reply(null);
337 |     };
338 | 
339 |     await createReflagInstance(callback);
340 | 
341 |     await vi.waitUntil(() => events.length > 2);
342 | 
343 |     expect(events).toEqual(["received", "shown", "dismissed"]);
344 | 
345 |     expect(markPromptMessageCompleted).toHaveBeenCalledOnce();
346 |     expect(markPromptMessageCompleted).toHaveBeenCalledWith(
347 |       "foo",
348 |       "123",
349 |       new Date(message.showBefore),
350 |     );
351 |   });
352 | 
353 |   test("propagates prompt to the callback and reacts to feedback", async () => {
354 |     const callback: FeedbackPromptHandler = async (_, handlers) => {
355 |       await handlers.reply({
356 |         companyId: "bar",
357 |         score: 5,
358 |         comment: "hello",
359 |         question: "Cum esti?",
360 |       });
361 |     };
362 | 
363 |     await createReflagInstance(callback);
364 | 
365 |     await vi.waitUntil(() => events.length > 1);
366 | 
367 |     expect(events).toEqual(["received", "shown"]);
368 |     expect(markPromptMessageCompleted).toHaveBeenCalledOnce();
369 |     expect(markPromptMessageCompleted).toHaveBeenCalledWith(
370 |       "foo",
371 |       "123",
372 |       new Date(message.showBefore),
373 |     );
374 |   });
375 | });
376 | 
377 | describe(`sends "check" events `, () => {
378 |   test("getFlags() does not send `check` events", async () => {
379 |     vi.spyOn(FlagsClient.prototype, "sendCheckEvent");
380 | 
381 |     const client = new ReflagClient({
382 |       publishableKey: KEY,
383 |       user: { id: "123" },
384 |     });
385 |     await client.initialize();
386 | 
387 |     expect(
388 |       vi.mocked(FlagsClient.prototype.sendCheckEvent),
389 |     ).toHaveBeenCalledTimes(0);
390 | 
391 |     const flagA = client.getFlags()?.flagA;
392 | 
393 |     expect(flagA?.isEnabled).toBe(true);
394 |     expect(
395 |       vi.mocked(FlagsClient.prototype.sendCheckEvent),
396 |     ).toHaveBeenCalledTimes(0);
397 |   });
398 | 
399 |   describe("getFlag", async () => {
400 |     afterEach(() => {
401 |       vi.clearAllMocks();
402 |     });
403 | 
404 |     it(`returns get the expected flag details`, async () => {
405 |       const client = new ReflagClient({
406 |         publishableKey: KEY,
407 |         user: { id: "uid" },
408 |         company: { id: "cid" },
409 |       });
410 | 
411 |       await client.initialize();
412 | 
413 |       expect(client.getFlag("flagA")).toStrictEqual({
414 |         isEnabled: true,
415 |         config: { key: undefined, payload: undefined },
416 |         track: expect.any(Function),
417 |         requestFeedback: expect.any(Function),
418 |         isEnabledOverride: null,
419 |         setIsEnabledOverride: expect.any(Function),
420 |       });
421 | 
422 |       expect(client.getFlag("flagB")).toStrictEqual({
423 |         isEnabled: true,
424 |         config: {
425 |           key: "gpt3",
426 |           payload: {
427 |             model: "gpt-something",
428 |             temperature: 0.5,
429 |           },
430 |         },
431 |         track: expect.any(Function),
432 |         requestFeedback: expect.any(Function),
433 |         isEnabledOverride: null,
434 |         setIsEnabledOverride: expect.any(Function),
435 |       });
436 | 
437 |       expect(client.getFlag("flagC")).toStrictEqual({
438 |         isEnabled: false,
439 |         config: { key: undefined, payload: undefined },
440 |         track: expect.any(Function),
441 |         requestFeedback: expect.any(Function),
442 |         isEnabledOverride: null,
443 |         setIsEnabledOverride: expect.any(Function),
444 |       });
445 |     });
446 | 
447 |     it(`does not send check events when offline`, async () => {
448 |       const postSpy = vi.spyOn(HttpClient.prototype, "post");
449 | 
450 |       const client = new ReflagClient({
451 |         publishableKey: KEY,
452 |         user: { id: "uid" },
453 |         company: { id: "cid" },
454 |         offline: true,
455 |       });
456 |       await client.initialize();
457 | 
458 |       const flagA = client.getFlag("flagA");
459 |       expect(flagA.isEnabled).toBe(false);
460 | 
461 |       expect(postSpy).not.toHaveBeenCalled();
462 |     });
463 | 
464 |     it(`sends check event when accessing "isEnabled"`, async () => {
465 |       const sendCheckEventSpy = vi.spyOn(
466 |         FlagsClient.prototype,
467 |         "sendCheckEvent",
468 |       );
469 | 
470 |       const postSpy = vi.spyOn(HttpClient.prototype, "post");
471 | 
472 |       const client = new ReflagClient({
473 |         publishableKey: KEY,
474 |         user: { id: "uid" },
475 |         company: { id: "cid" },
476 |       });
477 |       await client.initialize();
478 | 
479 |       const flagA = client.getFlag("flagA");
480 | 
481 |       expect(sendCheckEventSpy).toHaveBeenCalledTimes(0);
482 |       expect(flagA.isEnabled).toBe(true);
483 | 
484 |       expect(sendCheckEventSpy).toHaveBeenCalledTimes(1);
485 |       expect(sendCheckEventSpy).toHaveBeenCalledWith(
486 |         {
487 |           action: "check-is-enabled",
488 |           key: "flagA",
489 |           value: true,
490 |           version: 1,
491 |           missingContextFields: ["field1", "field2"],
492 |           ruleEvaluationResults: [false, true],
493 |         },
494 |         expect.any(Function),
495 |       );
496 | 
497 |       expect(postSpy).toHaveBeenCalledWith({
498 |         body: {
499 |           action: "check-is-enabled",
500 |           evalContext: {
501 |             company: {
502 |               id: "cid",
503 |             },
504 |             other: {},
505 |             user: {
506 |               id: "uid",
507 |             },
508 |           },
509 |           evalResult: true,
510 |           evalRuleResults: [false, true],
511 |           evalMissingFields: ["field1", "field2"],
512 |           key: "flagA",
513 |           targetingVersion: 1,
514 |         },
515 |         path: "features/events",
516 |       });
517 |     });
518 | 
519 |     it(`sends check event when accessing "config"`, async () => {
520 |       const postSpy = vi.spyOn(HttpClient.prototype, "post");
521 | 
522 |       const client = new ReflagClient({
523 |         publishableKey: KEY,
524 |         user: { id: "uid" },
525 |       });
526 | 
527 |       await client.initialize();
528 |       const flagB = client.getFlag("flagB");
529 |       expect(flagB.config).toMatchObject({
530 |         key: "gpt3",
531 |       });
532 | 
533 |       expect(postSpy).toHaveBeenCalledWith({
534 |         body: {
535 |           action: "check-config",
536 |           evalContext: {
537 |             company: undefined,
538 |             other: {},
539 |             user: {
540 |               id: "uid",
541 |             },
542 |           },
543 |           evalResult: {
544 |             key: "gpt3",
545 |             payload: { model: "gpt-something", temperature: 0.5 },
546 |           },
547 |           evalRuleResults: [true, false, false],
548 |           evalMissingFields: ["field3"],
549 |           key: "flagB",
550 |           targetingVersion: 12,
551 |         },
552 |         path: "features/events",
553 |       });
554 |     });
555 | 
556 |     it("sends check event for not-enabled flags", async () => {
557 |       // disabled flags don't appear in the API response
558 |       vi.spyOn(FlagsClient.prototype, "sendCheckEvent");
559 | 
560 |       const client = new ReflagClient({ publishableKey: KEY });
561 |       await client.initialize();
562 | 
563 |       const nonExistentFlag = client.getFlag("non-existent");
564 | 
565 |       expect(
566 |         vi.mocked(FlagsClient.prototype.sendCheckEvent),
567 |       ).toHaveBeenCalledTimes(0);
568 |       expect(nonExistentFlag.isEnabled).toBe(false);
569 | 
570 |       expect(
571 |         vi.mocked(FlagsClient.prototype.sendCheckEvent),
572 |       ).toHaveBeenCalledTimes(1);
573 |       expect(
574 |         vi.mocked(FlagsClient.prototype.sendCheckEvent),
575 |       ).toHaveBeenCalledWith(
576 |         {
577 |           action: "check-is-enabled",
578 |           value: false,
579 |           key: "non-existent",
580 |           version: undefined,
581 |         },
582 |         expect.any(Function),
583 |       );
584 |     });
585 | 
586 |     it("calls client.track with the flagKey", async () => {
587 |       const client = new ReflagClient({ publishableKey: KEY });
588 |       await client.initialize();
589 | 
590 |       const flag = client.getFlag("flag-1");
591 |       expect(flag).toStrictEqual({
592 |         isEnabled: false,
593 |         track: expect.any(Function),
594 |         requestFeedback: expect.any(Function),
595 |         config: { key: undefined, payload: undefined },
596 |         isEnabledOverride: null,
597 |         setIsEnabledOverride: expect.any(Function),
598 |       });
599 | 
600 |       vi.spyOn(client, "track");
601 | 
602 |       await flag.track();
603 | 
604 |       expect(client.track).toHaveBeenCalledWith("flag-1");
605 |     });
606 | 
607 |     it("calls client.requestFeedback with the flagKey", async () => {
608 |       const client = new ReflagClient({ publishableKey: KEY });
609 |       await client.initialize();
610 | 
611 |       const flag = client.getFlag("flag-1");
612 |       expect(flag).toStrictEqual({
613 |         isEnabled: false,
614 |         track: expect.any(Function),
615 |         requestFeedback: expect.any(Function),
616 |         config: { key: undefined, payload: undefined },
617 |         isEnabledOverride: null,
618 |         setIsEnabledOverride: expect.any(Function),
619 |       });
620 | 
621 |       vi.spyOn(client, "requestFeedback");
622 | 
623 |       flag.requestFeedback({
624 |         title: "Feedback",
625 |       });
626 | 
627 |       expect(client.requestFeedback).toHaveBeenCalledWith({
628 |         flagKey: "flag-1",
629 |         title: "Feedback",
630 |       });
631 |     });
632 |   });
633 | });
634 | 
```
Page 6/9FirstPrevNextLast