From 1e5a65afcf23242ec2d1302f7e33e16868631c54 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Fri, 12 Sep 2025 15:54:47 +0200 Subject: [PATCH 01/58] feat: add ability to bootstrap flags on server and populate clients --- packages/browser-sdk/package.json | 2 +- packages/browser-sdk/src/client.ts | 35 +- packages/browser-sdk/src/flag/flags.ts | 45 +- packages/browser-sdk/src/index.ts | 1 + packages/browser-sdk/test/client.test.ts | 110 +++++ packages/browser-sdk/test/flags.test.ts | 184 ++++++++ packages/node-sdk/package.json | 2 +- packages/node-sdk/src/client.ts | 98 ++-- packages/node-sdk/src/types.ts | 5 + packages/node-sdk/test/client.test.ts | 428 ++++++++++++++++++ .../openfeature-browser-provider/package.json | 2 +- .../openfeature-node-provider/package.json | 2 +- packages/react-sdk/dev/plain/tsconfig.json | 7 + packages/react-sdk/dev/plain/vite-env.d.ts | 1 + packages/react-sdk/package.json | 4 +- packages/react-sdk/src/index.tsx | 134 +++++- packages/react-sdk/test/usage.test.tsx | 325 +++++++++++++ packages/vue-sdk/package.json | 2 +- yarn.lock | 12 +- 19 files changed, 1302 insertions(+), 97 deletions(-) create mode 100644 packages/react-sdk/dev/plain/tsconfig.json create mode 100644 packages/react-sdk/dev/plain/vite-env.d.ts diff --git a/packages/browser-sdk/package.json b/packages/browser-sdk/package.json index 35807d7e..efcf0ac8 100644 --- a/packages/browser-sdk/package.json +++ b/packages/browser-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@reflag/browser-sdk", - "version": "1.1.0", + "version": "1.2.0", "packageManager": "yarn@4.1.1", "license": "MIT", "repository": { diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 8d080e2e..89e60ac5 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -10,6 +10,7 @@ import * as feedbackLib from "./feedback/ui"; import { CheckEvent, FallbackFlagOverride, + FetchedFlags, FlagsClient, RawFlags, } from "./flag/flags"; @@ -238,6 +239,12 @@ export type InitOptions = { */ offline?: boolean; + /** + * An object containing pre-fetched flags to be used instead of fetching them from the server. + * This is intended to be used with the Node-SDK getFlagsForBootstrap method. + */ + flags?: FetchedFlags; + /** * Flag keys for which `isEnabled` should fallback to true * if SDK fails to fetch flags from Reflag servers. If a record @@ -431,6 +438,7 @@ export class ReflagClient { }, this.logger, { + flags: opts.flags, expireTimeMs: opts.expireTimeMs, staleTimeMs: opts.staleTimeMs, fallbackFlags: opts.fallbackFlags, @@ -482,8 +490,10 @@ export class ReflagClient { * Initialize the Reflag SDK. * * Must be called before calling other SDK methods. + * + * @param bootstrap - Whether to bootstrap the client, fetching flags, sending user, and company events. */ - async initialize() { + async initialize(bootstrap = true) { const start = Date.now(); if (this.autoFeedback) { // do not block on automated feedback surveys initialization @@ -492,17 +502,20 @@ export class ReflagClient { }); } - await this.flagsClient.initialize(); - if (this.context.user && this.config.enableTracking) { - this.user().catch((e) => { - this.logger.error("error sending user", e); - }); - } + if (bootstrap) { + await this.flagsClient.initialize(); - if (this.context.company && this.config.enableTracking) { - this.company().catch((e) => { - this.logger.error("error sending company", e); - }); + if (this.context.user && this.config.enableTracking) { + this.user().catch((e) => { + this.logger.error("error sending user", e); + }); + } + + if (this.context.company && this.config.enableTracking) { + this.company().catch((e) => { + this.logger.error("error sending company", e); + }); + } } this.logger.info( diff --git a/packages/browser-sdk/src/flag/flags.ts b/packages/browser-sdk/src/flag/flags.ts index 1816d5c5..5bcd2b20 100644 --- a/packages/browser-sdk/src/flag/flags.ts +++ b/packages/browser-sdk/src/flag/flags.ts @@ -68,9 +68,6 @@ export type FetchedFlag = { const FLAGS_UPDATED_EVENT = "flagsUpdated"; -/** - * @internal - */ export type FetchedFlags = Record; export type RawFlag = FetchedFlag & { @@ -176,7 +173,7 @@ export interface CheckEvent { missingContextFields?: string[]; } -type context = { +type Context = { user?: Record; company?: Record; other?: Record; @@ -207,8 +204,9 @@ function getOverridesCache(): OverridesFlags { * @internal */ export class FlagsClient { + private initialized = false; private cache: FlagCache; - private fetchedFlags: FetchedFlags; + private fetchedFlags: FetchedFlags = {}; private flagOverrides: OverridesFlags = {}; private flags: RawFlags = {}; @@ -222,9 +220,10 @@ export class FlagsClient { constructor( private httpClient: HttpClient, - private context: context, + private context: Context, logger: Logger, options?: { + flags?: FetchedFlags; fallbackFlags?: Record | string[]; timeoutMs?: number; staleTimeMs?: number; @@ -234,7 +233,6 @@ export class FlagsClient { offline?: boolean; }, ) { - this.fetchedFlags = {}; this.logger = loggerWithPrefix(logger, "[Flags]"); this.cache = options?.cache ? options.cache @@ -280,14 +278,22 @@ export class FlagsClient { this.logger.warn("error getting flag overrides from cache", e); this.flagOverrides = {}; } + + if (options?.flags) { + this.initialized = true; + this.fetchedFlags = options.flags; + this.flags = this.mergeFlags(this.fetchedFlags, this.flagOverrides); + } } async initialize() { - const flags = (await this.maybeFetchFlags()) || {}; - this.setFetchedFlags(flags); + if (!this.initialized) { + this.initialized = true; + this.setFetchedFlags((await this.maybeFetchFlags()) || {}); + } } - async setContext(context: context) { + async setContext(context: Context) { this.context = context; await this.initialize(); } @@ -397,21 +403,20 @@ export class FlagsClient { return checkEvent.value; } - private triggerFlagsUpdated() { + private mergeFlags(fetchedFlags: FetchedFlags, overrides: OverridesFlags) { const mergedFlags: RawFlags = {}; - // merge fetched flags with overrides into `this.flags` - for (const key in this.fetchedFlags) { - const fetchedFlag = this.fetchedFlags[key]; + for (const key in fetchedFlags) { + const fetchedFlag = fetchedFlags[key]; if (!fetchedFlag) continue; - const isEnabledOverride = this.flagOverrides[key] ?? null; - mergedFlags[key] = { - ...fetchedFlag, - isEnabledOverride, - }; + const isEnabledOverride = overrides[key] ?? null; + mergedFlags[key] = { ...fetchedFlag, isEnabledOverride }; } + return mergedFlags; + } - this.flags = mergedFlags; + private triggerFlagsUpdated() { + this.flags = this.mergeFlags(this.fetchedFlags, this.flagOverrides); this.eventTarget.dispatchEvent(new Event(FLAGS_UPDATED_EVENT)); } diff --git a/packages/browser-sdk/src/index.ts b/packages/browser-sdk/src/index.ts index 0b521be0..6f988a6e 100644 --- a/packages/browser-sdk/src/index.ts +++ b/packages/browser-sdk/src/index.ts @@ -32,6 +32,7 @@ export type { CheckEvent, FallbackFlagOverride, FetchedFlag, + FetchedFlags, RawFlag, RawFlags, } from "./flag/flags"; diff --git a/packages/browser-sdk/test/client.test.ts b/packages/browser-sdk/test/client.test.ts index bfed86bb..f017c454 100644 --- a/packages/browser-sdk/test/client.test.ts +++ b/packages/browser-sdk/test/client.test.ts @@ -171,4 +171,114 @@ describe("ReflagClient", () => { expect(httpClientGet).not.toHaveBeenCalled(); }); }); + + describe("bootstrap parameter", () => { + const flagsClientInitialize = vi.spyOn(FlagsClient.prototype, "initialize"); + + beforeEach(() => { + flagsClientInitialize.mockClear(); + }); + + it("should skip flagsClient.initialize() when bootstrap is false", async () => { + client = new ReflagClient({ + publishableKey: "test-key", + user: { id: "user1" }, + company: { id: "company1" }, + feedback: { enableAutoFeedback: false }, // Disable to avoid HTTP calls + }); + + await client.initialize(false); + + expect(flagsClientInitialize).not.toHaveBeenCalled(); + expect(httpClientPost).not.toHaveBeenCalled(); // No user/company tracking + }); + + it("should use pre-fetched flags and skip initialization when flags are provided", async () => { + const preFetchedFlags = { + testFlag: { + key: "testFlag", + isEnabled: true, + targetingVersion: 1, + }, + }; + + // Create a spy to monitor maybeFetchFlags which should not be called if already initialized + const maybeFetchFlags = vi.spyOn( + FlagsClient.prototype as any, + "maybeFetchFlags", + ); + + client = new ReflagClient({ + publishableKey: "test-key", + user: { id: "user1" }, + company: { id: "company1" }, + flags: preFetchedFlags, + feedback: { enableAutoFeedback: false }, // Disable to avoid HTTP calls + }); + + // FlagsClient should be initialized in constructor when flags are provided + expect(client["flagsClient"]["initialized"]).toBe(true); + expect(client.getFlags()).toEqual({ + testFlag: { + key: "testFlag", + isEnabled: true, + targetingVersion: 1, + isEnabledOverride: null, + }, + }); + + maybeFetchFlags.mockClear(); + + await client.initialize(); + + // maybeFetchFlags should not be called since flagsClient is already initialized + expect(maybeFetchFlags).not.toHaveBeenCalled(); + }); + + it("should combine pre-fetched flags with bootstrap=false correctly", async () => { + const preFetchedFlags = { + testFlag: { + key: "testFlag", + isEnabled: true, + targetingVersion: 1, + config: { + key: "config1", + version: 1, + payload: { value: "test" }, + }, + }, + }; + + client = new ReflagClient({ + publishableKey: "test-key", + user: { id: "user1" }, + company: { id: "company1" }, + flags: preFetchedFlags, + feedback: { enableAutoFeedback: false }, // Disable to avoid HTTP calls + }); + + await client.initialize(false); + + // Should not call flagsClient.initialize() because bootstrap is false + expect(flagsClientInitialize).not.toHaveBeenCalled(); + // Should not make any HTTP calls for user/company tracking + expect(httpClientPost).not.toHaveBeenCalled(); + // Should have the pre-fetched flags available + expect(client.getFlags()).toEqual({ + testFlag: { + key: "testFlag", + isEnabled: true, + targetingVersion: 1, + config: { + key: "config1", + version: 1, + payload: { value: "test" }, + }, + isEnabledOverride: null, + }, + }); + // Should be able to use the flag + expect(client.getFlag("testFlag").isEnabled).toBe(true); + }); + }); }); diff --git a/packages/browser-sdk/test/flags.test.ts b/packages/browser-sdk/test/flags.test.ts index 42b3cba6..1ed3ab72 100644 --- a/packages/browser-sdk/test/flags.test.ts +++ b/packages/browser-sdk/test/flags.test.ts @@ -378,4 +378,188 @@ describe("FlagsClient", () => { expect(updated).toBe(true); expect(client.getFlags().flagC).toBeUndefined(); }); + + describe("pre-fetched flags", () => { + test("should be initialized when flags are provided in constructor", () => { + const { httpClient } = flagsClientFactory(); + const preFetchedFlags = { + testFlag: { + key: "testFlag", + isEnabled: true, + targetingVersion: 1, + }, + configFlag: { + key: "configFlag", + isEnabled: false, + targetingVersion: 2, + config: { + key: "config1", + version: 1, + payload: { value: "test" }, + }, + }, + }; + + const flagsClient = new FlagsClient( + httpClient, + { + user: { id: "123" }, + company: { id: "456" }, + other: { eventId: "big-conference1" }, + }, + testLogger, + { + flags: preFetchedFlags, + }, + ); + + // Should be initialized immediately when flags are provided + expect(flagsClient["initialized"]).toBe(true); + + // Should have the flags available + expect(flagsClient.getFlags()).toEqual({ + testFlag: { + key: "testFlag", + isEnabled: true, + targetingVersion: 1, + isEnabledOverride: null, + }, + configFlag: { + key: "configFlag", + isEnabled: false, + targetingVersion: 2, + config: { + key: "config1", + version: 1, + payload: { value: "test" }, + }, + isEnabledOverride: null, + }, + }); + }); + + test("should skip fetching when already initialized with pre-fetched flags", async () => { + const { httpClient } = flagsClientFactory(); + vi.spyOn(httpClient, "get"); + + const preFetchedFlags = { + testFlag: { + key: "testFlag", + isEnabled: true, + targetingVersion: 1, + }, + }; + + const flagsClient = new FlagsClient( + httpClient, + { + user: { id: "123" }, + company: { id: "456" }, + other: { eventId: "big-conference1" }, + }, + testLogger, + { + flags: preFetchedFlags, + }, + ); + + // Call initialize() after flags are already provided + await flagsClient.initialize(); + + // Should not have made any HTTP requests since already initialized + expect(httpClient.get).not.toHaveBeenCalled(); + + // Should still have the flags available + expect(flagsClient.getFlags()).toEqual({ + testFlag: { + key: "testFlag", + isEnabled: true, + targetingVersion: 1, + isEnabledOverride: null, + }, + }); + }); + + test("should trigger onUpdated when pre-fetched flags are set", async () => { + const { httpClient } = flagsClientFactory(); + const preFetchedFlags = { + testFlag: { + key: "testFlag", + isEnabled: true, + targetingVersion: 1, + }, + }; + + const flagsClient = new FlagsClient( + httpClient, + { + user: { id: "123" }, + company: { id: "456" }, + other: { eventId: "big-conference1" }, + }, + testLogger, + { + flags: preFetchedFlags, + }, + ); + + let updateTriggered = false; + flagsClient.onUpdated(() => { + updateTriggered = true; + }); + + // Trigger the flags updated event by setting context (which should not fetch since already initialized) + await flagsClient.setContext({ + user: { id: "456" }, + company: { id: "789" }, + other: { eventId: "other-conference" }, + }); + + expect(updateTriggered).toBe(false); // No update since context change doesn't affect pre-fetched flags + }); + + test("should work with fallback flags when initialization fails", async () => { + const { httpClient } = flagsClientFactory(); + vi.spyOn(httpClient, "get").mockRejectedValue( + new Error("Failed to fetch flags"), + ); + + const preFetchedFlags = { + testFlag: { + key: "testFlag", + isEnabled: true, + targetingVersion: 1, + }, + }; + + const flagsClient = new FlagsClient( + httpClient, + { + user: { id: "123" }, + company: { id: "456" }, + other: { eventId: "big-conference1" }, + }, + testLogger, + { + flags: preFetchedFlags, + fallbackFlags: ["fallbackFlag"], + }, + ); + + // Should be initialized with pre-fetched flags + expect(flagsClient["initialized"]).toBe(true); + expect(flagsClient.getFlags()).toEqual({ + testFlag: { + key: "testFlag", + isEnabled: true, + targetingVersion: 1, + isEnabledOverride: null, + }, + }); + + // Calling initialize again should not fetch since already initialized + await flagsClient.initialize(); + expect(httpClient.get).not.toHaveBeenCalled(); + }); + }); }); diff --git a/packages/node-sdk/package.json b/packages/node-sdk/package.json index c9906fb7..9add3470 100644 --- a/packages/node-sdk/package.json +++ b/packages/node-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@reflag/node-sdk", - "version": "1.0.1", + "version": "1.2.0", "license": "MIT", "repository": { "type": "git", diff --git a/packages/node-sdk/src/client.ts b/packages/node-sdk/src/client.ts index 859a3e0f..90388f43 100644 --- a/packages/node-sdk/src/client.ts +++ b/packages/node-sdk/src/client.ts @@ -23,6 +23,7 @@ import inRequestCache from "./inRequestCache"; import periodicallyUpdatingCache from "./periodicallyUpdatingCache"; import { newRateLimiter } from "./rate-limiter"; import type { + BootstrappedFlags, CachedFlagDefinition, CacheStrategy, EvaluatedFlagsAPIResponse, @@ -634,7 +635,14 @@ export class ReflagClient { enableTracking = true, ...context }: ContextWithTracking): TypedFlags { - return this._getFlags({ enableTracking, ...context }); + const contextWithTracking = { enableTracking, ...context }; + const rawFlags = this._getFlags(contextWithTracking); + return Object.fromEntries( + Object.entries(rawFlags).map(([key, rawFlag]) => [ + key, + this._wrapRawFlag(contextWithTracking, rawFlag), + ]), + ); } /** @@ -651,7 +659,40 @@ export class ReflagClient { { enableTracking = true, ...context }: ContextWithTracking, key: TKey, ): TypedFlags[TKey] { - return this._getFlags({ enableTracking, ...context }, key); + const contextWithTracking = { enableTracking, ...context }; + const rawFlag = this._getFlags(contextWithTracking, key); + return this._wrapRawFlag( + { enableChecks: true, ...contextWithTracking }, + rawFlag ?? { key }, + ); + } + + /** + * Gets the evaluated flags for the current context without wrapping them in getters. + * This method returns raw flag data suitable for bootstrapping client-side applications. + * + * @param options - The options for the context. + * @param options.enableTracking - Whether to enable tracking for the context. + * @param options.meta - The meta context associated with the context. + * @param options.user - The user context. + * @param options.company - The company context. + * @param options.other - The other context. + * + * @returns The evaluated raw flags and the context. + * + * @remarks + * Call `initialize` before calling this method to ensure the flag definitions are cached, no flags will be returned otherwise. + * This method returns RawFlag objects without wrapping them in getters, making them suitable for serialization. + **/ + public getFlagsForBootstrap({ + enableTracking = true, + meta, + ...context + }: ContextWithTracking): BootstrappedFlags { + return { + context, + flags: this._getFlags({ enableTracking, meta, ...context }), + }; } /** @@ -1003,15 +1044,17 @@ export class ReflagClient { } } - private _getFlags(options: ContextWithTracking): TypedFlags; + private _getFlags( + options: ContextWithTracking, + ): Record; private _getFlags( options: ContextWithTracking, key: TKey, - ): TypedFlags[TKey]; + ): RawFlag | undefined; private _getFlags( options: ContextWithTracking, key?: TKey, - ): TypedFlags | TypedFlags[TKey] { + ): Record | RawFlag | undefined { checkContextWithTracking(options); if (!this.initializationFinished) { @@ -1029,17 +1072,9 @@ export class ReflagClient { ); const fallbackFlags = this._config.fallbackFlags || {}; if (key) { - return this._wrapRawFlag( - { ...options, enableChecks: true }, - { key, ...fallbackFlags[key] }, - ); + return fallbackFlags[key]; } - return Object.fromEntries( - Object.entries(fallbackFlags).map(([k, v]) => [ - k as TypedFlagKey, - this._wrapRawFlag(options, v), - ]), - ); + return fallbackFlags; } flagDefinitions = flagDefs; } @@ -1112,18 +1147,10 @@ export class ReflagClient { } if (key) { - return this._wrapRawFlag( - { ...options, enableChecks: true }, - { key, ...evaluatedFlags[key] }, - ); + return evaluatedFlags[key]; } - return Object.fromEntries( - Object.entries(evaluatedFlags).map(([k, v]) => [ - k as TypedFlagKey, - this._wrapRawFlag(options, v), - ]), - ); + return evaluatedFlags; } private _wrapRawFlag( @@ -1335,6 +1362,16 @@ export class BoundReflagClient { return this._client.getFlags(this._options); } + /** + * Get raw flags for the user/company/other context bound to this client without wrapping them in getters. + * This method returns raw flag data suitable for bootstrapping client-side applications. + * + * @returns Raw flags for the given user/company and whether each one is enabled or not + */ + public getFlagsForBootstrap(): BootstrappedFlags { + return this._client.getFlagsForBootstrap(this._options); + } + /** * Get a specific flag for the user/company/other context bound to this client. * Using the `isEnabled` property sends a `check` event to Reflag. @@ -1464,9 +1501,7 @@ function checkMeta( ); } -function checkContextWithTracking( - context: ContextWithTracking, -): asserts context is ContextWithTracking & { enableTracking: boolean } { +function checkContext(context: Context): asserts context is Context { ok(isObject(context), "context must be an object"); ok( typeof context.user === "undefined" || isObject(context.user), @@ -1488,6 +1523,13 @@ function checkContextWithTracking( context.other === undefined || isObject(context.other), "other must be an object if given", ); +} + +function checkContextWithTracking( + context: ContextWithTracking, +): asserts context is ContextWithTracking & { enableTracking: boolean } { + checkContext(context); + ok( typeof context.enableTracking === "boolean", "enableTracking must be a boolean", diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index b3638d54..30d4548d 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -125,6 +125,11 @@ export interface RawFlag { missingContextFields?: string[]; } +export type BootstrappedFlags = { + context: Context; + flags: Record; +}; + export type EmptyFlagRemoteConfig = { key: undefined; payload: undefined }; /** diff --git a/packages/node-sdk/test/client.test.ts b/packages/node-sdk/test/client.test.ts index 4aed3e80..115c8097 100644 --- a/packages/node-sdk/test/client.test.ts +++ b/packages/node-sdk/test/client.test.ts @@ -1843,6 +1843,376 @@ describe("ReflagClient", () => { }); }); + describe("getFlagsForBootstrap", () => { + let client: ReflagClient; + + beforeEach(async () => { + httpClient.get.mockResolvedValue({ + ok: true, + status: 200, + body: { + success: true, + ...flagDefinitions, + }, + }); + + client = new ReflagClient(validOptions); + + client["rateLimiter"].clearStale(true); + + httpClient.post.mockResolvedValue({ + ok: true, + status: 200, + body: { success: true }, + }); + }); + + it("should return raw flags without wrapper functions", async () => { + httpClient.post.mockClear(); // not interested in updates + + await client.initialize(); + const result = client.getFlagsForBootstrap({ + company, + user, + other: otherContext, + enableTracking: true, + }); + + expect(result).toStrictEqual({ + context: { + company, + user, + other: otherContext, + }, + flags: { + flag1: { + key: "flag1", + isEnabled: true, + targetingVersion: 1, + config: { + key: "config-1", + payload: { + something: "else", + }, + targetingVersion: 1, + missingContextFields: [], + ruleEvaluationResults: [true], + }, + ruleEvaluationResults: [true], + missingContextFields: [], + }, + flag2: { + key: "flag2", + isEnabled: false, + targetingVersion: 2, + config: { + key: undefined, + payload: undefined, + targetingVersion: undefined, + missingContextFields: [], + ruleEvaluationResults: [], + }, + ruleEvaluationResults: [false], + missingContextFields: ["attributeKey"], + }, + }, + }); + + // Should not have track function like regular getFlags + expect(result.flags.flag1).not.toHaveProperty("track"); + expect(result.flags.flag2).not.toHaveProperty("track"); + + await client.flush(); + + expect(httpClient.post).toHaveBeenCalledTimes(1); + }); + + it("should return raw flags when only user is defined", async () => { + httpClient.post.mockClear(); // not interested in updates + + await client.initialize(); + const flags = client.getFlagsForBootstrap({ user }); + + expect(flags).toStrictEqual({ + context: { + user, + }, + flags: { + flag1: { + key: "flag1", + isEnabled: false, + targetingVersion: 1, + config: { + key: undefined, + payload: undefined, + targetingVersion: 1, + missingContextFields: ["company.id"], + ruleEvaluationResults: [false], + }, + ruleEvaluationResults: [false], + missingContextFields: ["company.id"], + }, + flag2: { + key: "flag2", + isEnabled: false, + targetingVersion: 2, + config: { + key: undefined, + payload: undefined, + targetingVersion: undefined, + missingContextFields: [], + ruleEvaluationResults: [], + }, + ruleEvaluationResults: [false], + missingContextFields: ["company.id"], + }, + }, + }); + + // Should not have track function + expect(flags.flags.flag1).not.toHaveProperty("track"); + expect(flags.flags.flag2).not.toHaveProperty("track"); + + await client.flush(); + + expect(httpClient.post).toHaveBeenCalledTimes(1); + }); + + it("should return raw flags when only company is defined", async () => { + await client.initialize(); + const flags = client.getFlagsForBootstrap({ company }); + + expect(flags).toStrictEqual({ + context: { + company, + }, + flags: { + flag1: { + key: "flag1", + isEnabled: true, + targetingVersion: 1, + config: { + key: "config-1", + payload: { + something: "else", + }, + targetingVersion: 1, + missingContextFields: [], + ruleEvaluationResults: [true], + }, + ruleEvaluationResults: [true], + missingContextFields: [], + }, + flag2: { + key: "flag2", + isEnabled: false, + targetingVersion: 2, + config: { + key: undefined, + payload: undefined, + targetingVersion: undefined, + missingContextFields: [], + ruleEvaluationResults: [], + }, + ruleEvaluationResults: [false], + missingContextFields: ["attributeKey"], + }, + }, + }); + + // Should not have track function + expect(flags.flags.flag1).not.toHaveProperty("track"); + expect(flags.flags.flag2).not.toHaveProperty("track"); + }); + + it("should return raw flags when only other context is defined", async () => { + await client.initialize(); + const flags = client.getFlagsForBootstrap({ other: otherContext }); + + expect(flags).toStrictEqual({ + context: { + other: otherContext, + }, + flags: { + flag1: { + key: "flag1", + isEnabled: false, + targetingVersion: 1, + config: { + key: undefined, + payload: undefined, + targetingVersion: 1, + missingContextFields: ["company.id"], + ruleEvaluationResults: [false], + }, + ruleEvaluationResults: [false], + missingContextFields: ["company.id"], + }, + flag2: { + key: "flag2", + isEnabled: false, + targetingVersion: 2, + config: { + key: undefined, + payload: undefined, + targetingVersion: undefined, + missingContextFields: [], + ruleEvaluationResults: [], + }, + ruleEvaluationResults: [false], + missingContextFields: ["company.id"], + }, + }, + }); + + // Should not have track function + expect(flags.flags.flag1).not.toHaveProperty("track"); + expect(flags.flags.flag2).not.toHaveProperty("track"); + }); + + it("should return fallback flags when client is not initialized", async () => { + const flags = client.getFlagsForBootstrap({ + company, + user, + other: otherContext, + enableTracking: true, + }); + + // Should return the fallback flags defined in validOptions + expect(flags).toStrictEqual({ + context: { + company, + user, + other: otherContext, + }, + flags: { + key: { + isEnabled: true, + key: "key", + }, + }, + }); + }); + + it("should return fallback flags when flag definitions are not available", async () => { + httpClient.get.mockResolvedValueOnce({ + ok: true, + status: 200, + body: { + success: true, + features: [], // No flag definitions + }, + }); + + await client.initialize(); + const flags = client.getFlagsForBootstrap({ + company, + user, + other: otherContext, + }); + + expect(flags).toStrictEqual({ + context: { + company, + user, + other: otherContext, + }, + flags: {}, + }); + }); + + it("should handle enableTracking parameter", async () => { + await client.initialize(); + + // Test with enableTracking: true (default) + const flagsWithTracking = client.getFlagsForBootstrap({ + company, + user, + other: otherContext, + enableTracking: true, + }); + + // Test with enableTracking: false + const flagsWithoutTracking = client.getFlagsForBootstrap({ + company, + user, + other: otherContext, + enableTracking: true, + }); + + // Both should return the same raw flag structure + expect(flagsWithTracking).toStrictEqual(flagsWithoutTracking); + + // Neither should have track functions + expect(flagsWithTracking.flags.flag1).not.toHaveProperty("track"); + expect(flagsWithoutTracking.flags.flag1).not.toHaveProperty("track"); + }); + + it("should properly define the rate limiter key", async () => { + const isAllowedSpy = vi.spyOn(client["rateLimiter"], "isAllowed"); + + await client.initialize(); + client.getFlagsForBootstrap({ user, company, other: otherContext }); + + expect(isAllowedSpy).toHaveBeenCalledWith("1GHpP+QfYperQ0AtD8bWPiRE4H0="); + }); + + it("should work in offline mode", async () => { + const offlineClient = new ReflagClient({ + ...validOptions, + offline: true, + }); + + const flags = offlineClient.getFlagsForBootstrap({ + company, + user, + other: otherContext, + enableTracking: true, + }); + + expect(flags).toStrictEqual({ + context: { + company, + user, + other: otherContext, + }, + flags: {}, + }); + }); + + it("should use fallback flags when provided and no definitions available", async () => { + const fallbackTestFlags = { + fallbackFlag: { + key: "fallbackFlag", + isEnabled: true, + config: { key: "fallback-config", payload: { test: "data" } }, + }, + }; + + const clientWithFallback = new ReflagClient({ + ...validOptions, + fallbackFlags: fallbackTestFlags, + }); + + // Don't initialize to simulate no flag definitions + const flags = clientWithFallback.getFlagsForBootstrap({ + company, + user, + other: otherContext, + enableTracking: true, + }); + + expect(flags).toStrictEqual({ + context: { + company, + user, + other: otherContext, + }, + flags: fallbackTestFlags, + }); + }); + }); + describe("getFlagsRemote", () => { let client: ReflagClient; @@ -2040,6 +2410,7 @@ describe("BoundReflagClient", () => { httpClient.post.mockResolvedValue(response); httpClient.get.mockResolvedValue({ + ok: true, status: 200, body: { success: true, @@ -2168,6 +2539,63 @@ describe("BoundReflagClient", () => { await boundClient.flush(); }); + it("should return raw flags for bootstrap from bound client", async () => { + // Ensure client is properly initialized + await client.initialize(); + const boundClient = client.bindClient({ + user, + company, + other: otherContext, + enableTracking: true, + }); + + const result = boundClient.getFlagsForBootstrap(); + + expect(result).toStrictEqual({ + context: { + user, + company, + other: otherContext, + }, + flags: { + flag1: { + key: "flag1", + isEnabled: true, + targetingVersion: 1, + config: { + key: "config-1", + payload: { + something: "else", + }, + targetingVersion: 1, + missingContextFields: [], + ruleEvaluationResults: [true], + }, + ruleEvaluationResults: [true], + missingContextFields: [], + }, + flag2: { + key: "flag2", + isEnabled: false, + targetingVersion: 2, + config: { + key: undefined, + payload: undefined, + targetingVersion: undefined, + missingContextFields: [], + ruleEvaluationResults: [], + }, + ruleEvaluationResults: [false], + missingContextFields: ["attributeKey"], + }, + }, + }); + + // Should not have track function like regular getFlags + expect(result.flags.flag1).not.toHaveProperty("track"); + expect(result.flags.flag2).not.toHaveProperty("track"); + }); + describe("getFlagRemote/getFlagsRemote", () => { beforeEach(async () => { httpClient.get.mockClear(); diff --git a/packages/openfeature-browser-provider/package.json b/packages/openfeature-browser-provider/package.json index 6c4f23e3..937ff45f 100644 --- a/packages/openfeature-browser-provider/package.json +++ b/packages/openfeature-browser-provider/package.json @@ -35,7 +35,7 @@ } }, "dependencies": { - "@reflag/browser-sdk": "1.1.0" + "@reflag/browser-sdk": "1.2.0" }, "devDependencies": { "@openfeature/core": "1.5.0", diff --git a/packages/openfeature-node-provider/package.json b/packages/openfeature-node-provider/package.json index 610d04f8..8ba82b9c 100644 --- a/packages/openfeature-node-provider/package.json +++ b/packages/openfeature-node-provider/package.json @@ -50,7 +50,7 @@ "vitest": "~1.6.0" }, "dependencies": { - "@reflag/node-sdk": "1.0.1" + "@reflag/node-sdk": "1.2.0" }, "peerDependencies": { "@openfeature/server-sdk": ">=1.16.1" diff --git a/packages/react-sdk/dev/plain/tsconfig.json b/packages/react-sdk/dev/plain/tsconfig.json new file mode 100644 index 00000000..d687947f --- /dev/null +++ b/packages/react-sdk/dev/plain/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "module": "ESNext" + }, + "include": ["."] +} diff --git a/packages/react-sdk/dev/plain/vite-env.d.ts b/packages/react-sdk/dev/plain/vite-env.d.ts new file mode 100644 index 00000000..11f02fe2 --- /dev/null +++ b/packages/react-sdk/dev/plain/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/packages/react-sdk/package.json b/packages/react-sdk/package.json index a643a3b4..9fcf9453 100644 --- a/packages/react-sdk/package.json +++ b/packages/react-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@reflag/react-sdk", - "version": "1.1.0", + "version": "2.0.0", "license": "MIT", "repository": { "type": "git", @@ -34,7 +34,7 @@ } }, "dependencies": { - "@reflag/browser-sdk": "1.1.0", + "@reflag/browser-sdk": "1.2.0", "canonical-json": "^0.0.4", "rollup": "^4.2.0" }, diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index 5bd226d3..ab957723 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -5,6 +5,7 @@ import React, { ReactNode, useContext, useEffect, + useMemo, useRef, useState, } from "react"; @@ -13,6 +14,7 @@ import canonicalJSON from "canonical-json"; import { CheckEvent, CompanyContext, + FetchedFlags, InitOptions, RawFlags, ReflagClient, @@ -111,24 +113,23 @@ export type TypedFlags = keyof Flags extends never : Flag; }; +export type BootstrappedFlags = { + context: ReflagContext; + flags: FetchedFlags; +}; + export type FlagKey = keyof TypedFlags; const SDK_VERSION = `react-sdk/${version}`; type ProviderContextType = { - client?: ReflagClient; - features: { - features: RawFlags; - isLoading: boolean; - }; + isLoading: boolean; provider: boolean; + client?: ReflagClient; }; const ProviderContext = createContext({ - features: { - features: {}, - isLoading: false, - }, + isLoading: false, provider: false, }); @@ -136,7 +137,7 @@ const ProviderContext = createContext({ * Props for the ReflagProvider. */ export type ReflagProps = ReflagContext & - InitOptions & { + Omit & { /** * Children to be rendered. */ @@ -174,8 +175,7 @@ export function ReflagProvider({ newReflagClient = (...args) => new ReflagClient(...args), ...config }: ReflagProps) { - const [featuresLoading, setFlagsLoading] = useState(true); - const [rawFlags, setRawFlags] = useState({}); + const [isLoading, setIsLoading] = useState(true); const clientRef = useRef(); const contextKeyRef = useRef(); @@ -196,7 +196,7 @@ export function ReflagProvider({ void clientRef.current.stop(); } - setFlagsLoading(true); + setIsLoading(true); const client = newReflagClient({ ...config, @@ -210,27 +210,113 @@ export function ReflagProvider({ clientRef.current = client; - client.on("flagsUpdated", setRawFlags); - client .initialize() .catch((e) => { client.logger.error("failed to initialize client", e); }) + .finally(() => { + setIsLoading(false); + }); + // eslint-disable-next-line react-hooks/exhaustive-deps -- should only run once + }, [contextKey]); + + const context: ProviderContextType = useMemo( + () => ({ + isLoading: isLoading, + client: clientRef.current, + provider: true, + }), + [isLoading], + ); + + return ( + + {isLoading && typeof loadingComponent !== "undefined" + ? loadingComponent + : children} + + ); +} + +type ReflagBootstrappedProps = Omit< + ReflagProps, + "user" | "company" | "otherContext" +> & { + flags?: BootstrappedFlags; +}; + +/** + * Bootstrapped Provider for the ReflagClient using pre-fetched flags. + */ +export function ReflagBootstrappedProvider({ + flags, + children, + loadingComponent, + newReflagClient = (...args) => new ReflagClient(...args), + ...config +}: ReflagBootstrappedProps) { + const [featuresLoading, setFlagsLoading] = useState(true); + + const clientRef = useRef(); + const contextKeyRef = useRef(); + + const contextKey = canonicalJSON({ + config, + flags: flags?.flags, + ...flags?.context, + }); + + useEffect(() => { + // todo: will be stuck in loading state if flags fail to load + if (!flags) { + return; + } + // useEffect will run twice in development mode + // This is a workaround to prevent re-initialization + if (contextKeyRef.current === contextKey) { + return; + } + contextKeyRef.current = contextKey; + + // on update of contextKey and on unmount + if (clientRef.current) { + void clientRef.current.stop(); + } + + const client = newReflagClient({ + ...config, + flags: flags.flags, + user: flags.context.user, + company: flags.context.company, + otherContext: flags.context.otherContext, + + logger: config.debug ? console : undefined, + sdkVersion: SDK_VERSION, + }); + + clientRef.current = client; + + client + .initialize(false) + .catch((e) => { + client.logger.error("failed to initialize client", e); + }) .finally(() => { setFlagsLoading(false); }); // eslint-disable-next-line react-hooks/exhaustive-deps -- should only run once }, [contextKey]); - const context: ProviderContextType = { - features: { - features: rawFlags, + const context: ProviderContextType = useMemo( + () => ({ isLoading: featuresLoading, - }, - client: clientRef.current, - provider: true, - }; + client: clientRef.current, + provider: true, + }), + [featuresLoading], + ); + return ( {featuresLoading && typeof loadingComponent !== "undefined" @@ -265,9 +351,7 @@ export function useFeature(key: TKey) { */ export function useFlag(key: TKey): TypedFlags[TKey] { const client = useClient(); - const { - features: { isLoading }, - } = useContext(ProviderContext); + const { isLoading } = useContext(ProviderContext); const track = () => client?.track(key); const requestFeedback = (opts: RequestFeedbackOptions) => diff --git a/packages/react-sdk/test/usage.test.tsx b/packages/react-sdk/test/usage.test.tsx index 43a717bf..4ceaa78e 100644 --- a/packages/react-sdk/test/usage.test.tsx +++ b/packages/react-sdk/test/usage.test.tsx @@ -17,6 +17,8 @@ import { ReflagClient } from "@reflag/browser-sdk"; import { version } from "../package.json"; import { + BootstrappedFlags, + ReflagBootstrappedProvider, ReflagProps, ReflagProvider, useClient, @@ -54,6 +56,19 @@ function getProvider(props: Partial = {}) { ); } +function getBootstrapProvider( + bootstrapFlags?: BootstrappedFlags, + props: Partial> = {}, +) { + return ( + + ); +} + const server = setupServer( http.post(/\/event$/, () => { events.push("EVENT"); @@ -499,3 +514,313 @@ describe("useClient", () => { unmount(); }); }); + +describe("", () => { + test("calls initialize with pre-fetched flags", () => { + const on = vi.fn(); + + const newReflagClient = vi.fn().mockReturnValue({ + initialize: vi.fn().mockResolvedValue(undefined), + on, + }); + + const bootstrapFlags: BootstrappedFlags = { + context: { + user: { id: "456", name: "test" }, + company: { id: "123", name: "test" }, + otherContext: { test: "test" }, + }, + flags: { + abc: { + key: "abc", + isEnabled: true, + targetingVersion: 1, + config: { + key: "gpt3", + payload: { model: "gpt-something", temperature: 0.5 }, + version: 2, + }, + }, + def: { + key: "def", + isEnabled: true, + targetingVersion: 2, + }, + }, + }; + + const provider = getBootstrapProvider(bootstrapFlags, { + publishableKey: "KEY", + apiBaseUrl: "https://apibaseurl.com", + sseBaseUrl: "https://ssebaseurl.com", + enableTracking: false, + appBaseUrl: "https://appbaseurl.com", + staleTimeMs: 1001, + timeoutMs: 1002, + expireTimeMs: 1003, + staleWhileRevalidate: true, + fallbackFlags: ["flag2"], + feedback: { enableAutoFeedback: true }, + toolbar: { show: true }, + newReflagClient, + }); + + render(provider); + + expect(newReflagClient.mock.calls.at(0)).toStrictEqual([ + { + publishableKey: "KEY", + user: { + id: "456", + name: "test", + }, + company: { + id: "123", + name: "test", + }, + otherContext: { + test: "test", + }, + flags: { + abc: { + key: "abc", + isEnabled: true, + targetingVersion: 1, + config: { + key: "gpt3", + payload: { model: "gpt-something", temperature: 0.5 }, + version: 2, + }, + }, + def: { + key: "def", + isEnabled: true, + targetingVersion: 2, + }, + }, + apiBaseUrl: "https://apibaseurl.com", + appBaseUrl: "https://appbaseurl.com", + sseBaseUrl: "https://ssebaseurl.com", + logger: undefined, + enableTracking: false, + expireTimeMs: 1003, + fallbackFlags: ["flag2"], + feedback: { + enableAutoFeedback: true, + }, + staleTimeMs: 1001, + staleWhileRevalidate: true, + timeoutMs: 1002, + toolbar: { + show: true, + }, + sdkVersion: `react-sdk/${version}`, + }, + ]); + + expect(on).toBeTruthy(); + }); + + test("calls initialize with false parameter for bootstrap mode", () => { + const initialize = vi.fn().mockResolvedValue(undefined); + + const newReflagClient = vi.fn().mockReturnValue({ + initialize, + on: vi.fn(), + }); + + const bootstrapFlags: BootstrappedFlags = { + context: { + user: { id: "456", name: "test" }, + company: { id: "123", name: "test" }, + otherContext: { test: "test" }, + }, + flags: { + abc: { + key: "abc", + isEnabled: true, + targetingVersion: 1, + }, + }, + }; + + const provider = getBootstrapProvider(bootstrapFlags, { + newReflagClient, + }); + + render(provider); + + expect(initialize).toHaveBeenCalledWith(false); + }); + + test("does not initialize when no flags are provided", () => { + const initialize = vi.fn().mockResolvedValue(undefined); + + const newReflagClient = vi.fn().mockReturnValue({ + initialize, + on: vi.fn(), + }); + + const provider = getBootstrapProvider(undefined, { + newReflagClient, + }); + + render(provider); + + expect(initialize).not.toHaveBeenCalled(); + expect(newReflagClient).not.toHaveBeenCalled(); + }); + + test("shows loading component initially and hides it after initialization", async () => { + const bootstrapFlags: BootstrappedFlags = { + context: { + user: { id: "456", name: "test" }, + company: { id: "123", name: "test" }, + otherContext: { test: "test" }, + }, + flags: { + abc: { + key: "abc", + isEnabled: true, + targetingVersion: 1, + }, + }, + }; + + const { queryByTestId } = render( + getBootstrapProvider(bootstrapFlags, { + loadingComponent: Loading..., + }), + ); + + // Loading component should be visible initially + expect(queryByTestId("loading")).not.toBeNull(); + + // Wait for loading to complete + await waitFor(() => { + expect(queryByTestId("loading")).toBeNull(); + }); + }); + + test("shows loading component when no flags are provided", () => { + const { queryByTestId } = render( + getBootstrapProvider(undefined, { + loadingComponent: Loading..., + children: Content, + }), + ); + + // Loading should be visible when no flags are provided since no client is initialized + expect(queryByTestId("loading")).not.toBeNull(); + expect(queryByTestId("content")).toBeNull(); + }); +}); + +describe("useFlag with ReflagBootstrappedProvider", () => { + test("returns bootstrapped flag values immediately", async () => { + const bootstrapFlags: BootstrappedFlags = { + context: { + user: { id: "456", name: "test" }, + company: { id: "123", name: "test" }, + otherContext: { test: "test" }, + }, + flags: { + abc: { + key: "abc", + isEnabled: true, + targetingVersion: 1, + config: { + key: "gpt3", + payload: { model: "gpt-something", temperature: 0.5 }, + version: 2, + }, + }, + def: { + key: "def", + isEnabled: true, + targetingVersion: 2, + }, + }, + }; + + const { result, unmount } = renderHook(() => useFlag("abc"), { + wrapper: ({ children }) => + getBootstrapProvider(bootstrapFlags, { children }), + }); + + // Initially loading + expect(result.current.isLoading).toBe(true); + + await waitFor(() => { + expect(result.current).toStrictEqual({ + key: "abc", + isEnabled: true, + isLoading: false, + config: { + key: "gpt3", + payload: { model: "gpt-something", temperature: 0.5 }, + }, + track: expect.any(Function), + requestFeedback: expect.any(Function), + }); + }); + + unmount(); + }); + + test("returns disabled flag for non-existent flags", async () => { + const bootstrapFlags: BootstrappedFlags = { + context: { + user: { id: "456", name: "test" }, + company: { id: "123", name: "test" }, + otherContext: { test: "test" }, + }, + flags: { + abc: { + key: "abc", + isEnabled: true, + targetingVersion: 1, + }, + }, + }; + + const { result, unmount } = renderHook(() => useFlag("nonexistent"), { + wrapper: ({ children }) => + getBootstrapProvider(bootstrapFlags, { children }), + }); + + await waitFor(() => { + expect(result.current).toStrictEqual({ + key: "nonexistent", + isEnabled: false, + isLoading: false, + config: { + key: undefined, + payload: undefined, + }, + track: expect.any(Function), + requestFeedback: expect.any(Function), + }); + }); + + unmount(); + }); + + test("returns loading state when no flags are bootstrapped", async () => { + const { result, unmount } = renderHook(() => useFlag("abc"), { + wrapper: ({ children }) => getBootstrapProvider(undefined, { children }), + }); + + // Should remain in loading state since no client is initialized + expect(result.current).toStrictEqual({ + key: "abc", + isEnabled: false, + isLoading: true, + config: { key: undefined, payload: undefined }, + track: expect.any(Function), + requestFeedback: expect.any(Function), + }); + + unmount(); + }); +}); diff --git a/packages/vue-sdk/package.json b/packages/vue-sdk/package.json index 1a23a23a..c6160754 100644 --- a/packages/vue-sdk/package.json +++ b/packages/vue-sdk/package.json @@ -34,7 +34,7 @@ } }, "dependencies": { - "@reflag/browser-sdk": "1.1.0", + "@reflag/browser-sdk": "1.2.0", "canonical-json": "^0.2.0" }, "peerDependencies": { diff --git a/yarn.lock b/yarn.lock index 015a0514..6221c220 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2952,7 +2952,7 @@ __metadata: languageName: node linkType: hard -"@reflag/browser-sdk@npm:1.1.0, @reflag/browser-sdk@workspace:packages/browser-sdk": +"@reflag/browser-sdk@npm:1.2.0, @reflag/browser-sdk@workspace:packages/browser-sdk": version: 0.0.0-use.local resolution: "@reflag/browser-sdk@workspace:packages/browser-sdk" dependencies: @@ -3055,7 +3055,7 @@ __metadata: languageName: unknown linkType: soft -"@reflag/node-sdk@npm:1.0.1, @reflag/node-sdk@workspace:packages/node-sdk": +"@reflag/node-sdk@npm:1.2.0, @reflag/node-sdk@workspace:packages/node-sdk": version: 0.0.0-use.local resolution: "@reflag/node-sdk@workspace:packages/node-sdk" dependencies: @@ -3083,7 +3083,7 @@ __metadata: dependencies: "@openfeature/core": "npm:1.5.0" "@openfeature/web-sdk": "npm:^1.3.0" - "@reflag/browser-sdk": "npm:1.1.0" + "@reflag/browser-sdk": "npm:1.2.0" "@reflag/eslint-config": "npm:0.0.2" "@reflag/tsconfig": "npm:0.0.2" "@types/node": "npm:^22.12.0" @@ -3107,7 +3107,7 @@ __metadata: "@openfeature/core": "npm:^1.5.0" "@openfeature/server-sdk": "npm:>=1.16.1" "@reflag/eslint-config": "npm:~0.0.2" - "@reflag/node-sdk": "npm:1.0.1" + "@reflag/node-sdk": "npm:1.2.0" "@reflag/tsconfig": "npm:~0.0.2" "@types/node": "npm:^22.12.0" eslint: "npm:^9.21.0" @@ -3127,7 +3127,7 @@ __metadata: version: 0.0.0-use.local resolution: "@reflag/react-sdk@workspace:packages/react-sdk" dependencies: - "@reflag/browser-sdk": "npm:1.1.0" + "@reflag/browser-sdk": "npm:1.2.0" "@reflag/eslint-config": "npm:^0.0.2" "@reflag/tsconfig": "npm:^0.0.2" "@testing-library/react": "npm:^15.0.7" @@ -3168,7 +3168,7 @@ __metadata: version: 0.0.0-use.local resolution: "@reflag/vue-sdk@workspace:packages/vue-sdk" dependencies: - "@reflag/browser-sdk": "npm:1.1.0" + "@reflag/browser-sdk": "npm:1.2.0" "@reflag/eslint-config": "npm:^0.0.2" "@reflag/tsconfig": "npm:^0.0.2" "@types/jsdom": "npm:^21.1.6" From 8d7f44c91ab319778a2322927b59afcbed1283fb Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Mon, 15 Sep 2025 12:51:41 +0200 Subject: [PATCH 02/58] docs: add readme for new getFlagsForBootstrap method --- packages/node-sdk/README.md | 64 ++++++++++++++++++++++++++++++++++ packages/node-sdk/src/types.ts | 3 ++ 2 files changed, 67 insertions(+) diff --git a/packages/node-sdk/README.md b/packages/node-sdk/README.md index 385dfb35..44e587f1 100644 --- a/packages/node-sdk/README.md +++ b/packages/node-sdk/README.md @@ -206,6 +206,70 @@ const flagDefs = await client.getFlagDefinitions(); // }] ``` +## Bootstrapping client-side applications + +The `getFlagsForBootstrap()` method is designed for server-side rendering (SSR) scenarios where you need to pass flag data to client-side applications. This method returns raw flag data without wrapper functions, making it suitable for serialization and client-side hydration. + +```typescript +const client = new ReflagClient(); +await client.initialize(); + +// Get flags for bootstrapping with full context +const { context, flags } = client.getFlagsForBootstrap({ + user: { + id: "john_doe", + name: "John Doe", + email: "john@acme.com", + }, + company: { + id: "acme_inc", + name: "Acme, Inc.", + }, + other: { + location: "US", + platform: "web", + }, +}); + +// Pass this data to your client-side application +// The flags object contains raw flag data suitable for JSON serialization +console.log(flags); +// { +// "huddle": { +// "key": "huddle", +// "isEnabled": true, +// "config": { +// "key": "pro", +// "payload": { "maxParticipants": 10 }, +// } +// } +// } +``` + +You can also use a bound client for simpler API: + +```typescript +const boundClient = client.bindClient({ + user: { id: "john_doe" }, + company: { id: "acme_inc" }, +}); + +const { context, flags } = boundClient.getFlagsForBootstrap(); +``` + +### Key differences from `getFlags()` + +- **Raw data**: Returns plain objects without `track()` functions, making them JSON serializable +- **Context included**: Returns both the evaluated flags and the context used for evaluation +- **Bootstrapping focus**: Designed specifically for passing data to client-side applications + +### Common use cases + +1. **Server-side rendering**: Pass initial flag state to React/Vue/Angular applications +2. **API responses**: Include flag data in JSON API responses for client consumption +3. **Static site generation**: Evaluate flags at build time for static sites +4. **Mobile app configuration**: Send flag configuration to mobile applications on startup + ## Edge-runtimes like Cloudflare Workers To use the Reflag NodeSDK with Cloudflare workers, set the `node_compat` flag [in your wrangler file](https://developers.cloudflare.com/workers/runtime-apis/nodejs/#get-started). diff --git a/packages/node-sdk/src/types.ts b/packages/node-sdk/src/types.ts index 30d4548d..5b80e313 100644 --- a/packages/node-sdk/src/types.ts +++ b/packages/node-sdk/src/types.ts @@ -125,6 +125,9 @@ export interface RawFlag { missingContextFields?: string[]; } +/** + * Describes a collection of evaluated raw flags and the context for bootstrapping. + */ export type BootstrappedFlags = { context: Context; flags: Record; From d9b307f27dd3ec22c8c2b0a4bb186ba496b7e317 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Mon, 15 Sep 2025 12:52:43 +0200 Subject: [PATCH 03/58] docs: add code comments to react sdk --- packages/react-sdk/src/index.tsx | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index ab957723..7889c587 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -239,10 +239,16 @@ export function ReflagProvider({ ); } +/** + * Props for the ReflagBootstrappedProvider. + */ type ReflagBootstrappedProps = Omit< ReflagProps, "user" | "company" | "otherContext" > & { + /** + * Pre-fetched flags to be used instead of fetching them from the server. + */ flags?: BootstrappedFlags; }; From 090d631d056847c7a9c9ae4f736df5cd2b9a1dcc Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Mon, 15 Sep 2025 13:51:06 +0200 Subject: [PATCH 04/58] fix: export types --- packages/browser-sdk/src/client.ts | 2 +- packages/node-sdk/src/index.ts | 1 + packages/react-sdk/src/index.tsx | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 89e60ac5..56e86258 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -491,7 +491,7 @@ export class ReflagClient { * * Must be called before calling other SDK methods. * - * @param bootstrap - Whether to bootstrap the client, fetching flags, sending user, and company events. + * @param bootstrap - Whether to bootstrap the client: fetching flags, sending user, and company events. */ async initialize(bootstrap = true) { const start = Date.now(); diff --git a/packages/node-sdk/src/index.ts b/packages/node-sdk/src/index.ts index b3140351..d9ac6ac3 100644 --- a/packages/node-sdk/src/index.ts +++ b/packages/node-sdk/src/index.ts @@ -3,6 +3,7 @@ export { EdgeClient, EdgeClientOptions } from "./edgeClient"; export type { Attributes, BatchBufferOptions, + BootstrappedFlags, CacheStrategy, ClientOptions, Context, diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index 7889c587..b4342706 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -242,7 +242,7 @@ export function ReflagProvider({ /** * Props for the ReflagBootstrappedProvider. */ -type ReflagBootstrappedProps = Omit< +export type ReflagBootstrappedProps = Omit< ReflagProps, "user" | "company" | "otherContext" > & { From 2d5954d9e6bd7b54f6a206fe1b4e2ceab771b231 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Mon, 15 Sep 2025 16:56:44 +0200 Subject: [PATCH 05/58] refactor: use bootstrapped flags to determine if bootstrap is needed --- packages/browser-sdk/src/client.ts | 31 +++++++++--- packages/browser-sdk/src/flag/flags.ts | 13 ++--- packages/browser-sdk/test/client.test.ts | 62 +----------------------- packages/browser-sdk/test/flags.test.ts | 8 +-- packages/react-sdk/src/index.tsx | 4 +- 5 files changed, 39 insertions(+), 79 deletions(-) diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 56e86258..d8690b74 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -171,6 +171,16 @@ export interface Config { * Whether to enable offline mode. */ offline: boolean; + + /** + * Whether the client is bootstrapped. + */ + bootstrapped: boolean; + + /** + * Whether the client is initialized. + */ + initialized: boolean; } /** @@ -243,7 +253,7 @@ export type InitOptions = { * An object containing pre-fetched flags to be used instead of fetching them from the server. * This is intended to be used with the Node-SDK getFlagsForBootstrap method. */ - flags?: FetchedFlags; + bootstrappedFlags?: FetchedFlags; /** * Flag keys for which `isEnabled` should fallback to true @@ -312,6 +322,8 @@ const defaultConfig: Config = { sseBaseUrl: SSE_REALTIME_BASE_URL, enableTracking: true, offline: false, + bootstrapped: false, + initialized: false, }; /** @@ -415,6 +427,8 @@ export class ReflagClient { sseBaseUrl: opts?.sseBaseUrl ?? defaultConfig.sseBaseUrl, enableTracking: opts?.enableTracking ?? defaultConfig.enableTracking, offline: opts?.offline ?? defaultConfig.offline, + bootstrapped: !!opts.bootstrappedFlags, + initialized: false, }; this.requestFeedbackOptions = { @@ -438,7 +452,7 @@ export class ReflagClient { }, this.logger, { - flags: opts.flags, + bootstrappedFlags: opts.bootstrappedFlags, expireTimeMs: opts.expireTimeMs, staleTimeMs: opts.staleTimeMs, fallbackFlags: opts.fallbackFlags, @@ -490,10 +504,13 @@ export class ReflagClient { * Initialize the Reflag SDK. * * Must be called before calling other SDK methods. - * - * @param bootstrap - Whether to bootstrap the client: fetching flags, sending user, and company events. */ - async initialize(bootstrap = true) { + async initialize() { + if (this.config.initialized) { + this.logger.info("Reflag client already initialized"); + return; + } + const start = Date.now(); if (this.autoFeedback) { // do not block on automated feedback surveys initialization @@ -502,7 +519,7 @@ export class ReflagClient { }); } - if (bootstrap) { + if (!this.config.bootstrapped) { await this.flagsClient.initialize(); if (this.context.user && this.config.enableTracking) { @@ -516,6 +533,7 @@ export class ReflagClient { this.logger.error("error sending company", e); }); } + this.config.bootstrapped = true; } this.logger.info( @@ -524,6 +542,7 @@ export class ReflagClient { "ms" + (this.config.offline ? " (offline mode)" : ""), ); + this.config.initialized = true; } /** diff --git a/packages/browser-sdk/src/flag/flags.ts b/packages/browser-sdk/src/flag/flags.ts index 5bcd2b20..e7ccadfe 100644 --- a/packages/browser-sdk/src/flag/flags.ts +++ b/packages/browser-sdk/src/flag/flags.ts @@ -223,7 +223,7 @@ export class FlagsClient { private context: Context, logger: Logger, options?: { - flags?: FetchedFlags; + bootstrappedFlags?: FetchedFlags; fallbackFlags?: Record | string[]; timeoutMs?: number; staleTimeMs?: number; @@ -279,18 +279,19 @@ export class FlagsClient { this.flagOverrides = {}; } - if (options?.flags) { + if (options?.bootstrappedFlags) { this.initialized = true; - this.fetchedFlags = options.flags; + this.fetchedFlags = options.bootstrappedFlags; this.flags = this.mergeFlags(this.fetchedFlags, this.flagOverrides); } } async initialize() { - if (!this.initialized) { - this.initialized = true; - this.setFetchedFlags((await this.maybeFetchFlags()) || {}); + if (this.initialized) { + this.logger.error("flags client already initialized"); } + this.setFetchedFlags((await this.maybeFetchFlags()) || {}); + this.initialized = true; } async setContext(context: Context) { diff --git a/packages/browser-sdk/test/client.test.ts b/packages/browser-sdk/test/client.test.ts index f017c454..943d6c7f 100644 --- a/packages/browser-sdk/test/client.test.ts +++ b/packages/browser-sdk/test/client.test.ts @@ -179,20 +179,6 @@ describe("ReflagClient", () => { flagsClientInitialize.mockClear(); }); - it("should skip flagsClient.initialize() when bootstrap is false", async () => { - client = new ReflagClient({ - publishableKey: "test-key", - user: { id: "user1" }, - company: { id: "company1" }, - feedback: { enableAutoFeedback: false }, // Disable to avoid HTTP calls - }); - - await client.initialize(false); - - expect(flagsClientInitialize).not.toHaveBeenCalled(); - expect(httpClientPost).not.toHaveBeenCalled(); // No user/company tracking - }); - it("should use pre-fetched flags and skip initialization when flags are provided", async () => { const preFetchedFlags = { testFlag: { @@ -212,7 +198,7 @@ describe("ReflagClient", () => { publishableKey: "test-key", user: { id: "user1" }, company: { id: "company1" }, - flags: preFetchedFlags, + bootstrappedFlags: preFetchedFlags, feedback: { enableAutoFeedback: false }, // Disable to avoid HTTP calls }); @@ -234,51 +220,5 @@ describe("ReflagClient", () => { // maybeFetchFlags should not be called since flagsClient is already initialized expect(maybeFetchFlags).not.toHaveBeenCalled(); }); - - it("should combine pre-fetched flags with bootstrap=false correctly", async () => { - const preFetchedFlags = { - testFlag: { - key: "testFlag", - isEnabled: true, - targetingVersion: 1, - config: { - key: "config1", - version: 1, - payload: { value: "test" }, - }, - }, - }; - - client = new ReflagClient({ - publishableKey: "test-key", - user: { id: "user1" }, - company: { id: "company1" }, - flags: preFetchedFlags, - feedback: { enableAutoFeedback: false }, // Disable to avoid HTTP calls - }); - - await client.initialize(false); - - // Should not call flagsClient.initialize() because bootstrap is false - expect(flagsClientInitialize).not.toHaveBeenCalled(); - // Should not make any HTTP calls for user/company tracking - expect(httpClientPost).not.toHaveBeenCalled(); - // Should have the pre-fetched flags available - expect(client.getFlags()).toEqual({ - testFlag: { - key: "testFlag", - isEnabled: true, - targetingVersion: 1, - config: { - key: "config1", - version: 1, - payload: { value: "test" }, - }, - isEnabledOverride: null, - }, - }); - // Should be able to use the flag - expect(client.getFlag("testFlag").isEnabled).toBe(true); - }); }); }); diff --git a/packages/browser-sdk/test/flags.test.ts b/packages/browser-sdk/test/flags.test.ts index 1ed3ab72..9e7004e8 100644 --- a/packages/browser-sdk/test/flags.test.ts +++ b/packages/browser-sdk/test/flags.test.ts @@ -409,7 +409,7 @@ describe("FlagsClient", () => { }, testLogger, { - flags: preFetchedFlags, + bootstrappedFlags: preFetchedFlags, }, ); @@ -459,7 +459,7 @@ describe("FlagsClient", () => { }, testLogger, { - flags: preFetchedFlags, + bootstrappedFlags: preFetchedFlags, }, ); @@ -499,7 +499,7 @@ describe("FlagsClient", () => { }, testLogger, { - flags: preFetchedFlags, + bootstrappedFlags: preFetchedFlags, }, ); @@ -541,7 +541,7 @@ describe("FlagsClient", () => { }, testLogger, { - flags: preFetchedFlags, + bootstrappedFlags: preFetchedFlags, fallbackFlags: ["fallbackFlag"], }, ); diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index b4342706..569b7b77 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -292,7 +292,7 @@ export function ReflagBootstrappedProvider({ const client = newReflagClient({ ...config, - flags: flags.flags, + bootstrappedFlags: flags.flags, user: flags.context.user, company: flags.context.company, otherContext: flags.context.otherContext, @@ -304,7 +304,7 @@ export function ReflagBootstrappedProvider({ clientRef.current = client; client - .initialize(false) + .initialize() .catch((e) => { client.logger.error("failed to initialize client", e); }) From 03c92abf5b5e81372be1ff74f6e3dd9405da4fae Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Tue, 16 Sep 2025 11:06:18 +0200 Subject: [PATCH 06/58] fix: separate bootstrap type from regular --- packages/browser-sdk/src/client.ts | 47 ++++++++++++++++++++---------- packages/browser-sdk/src/index.ts | 1 + 2 files changed, 33 insertions(+), 15 deletions(-) diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index d8690b74..0d2e36a7 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -249,12 +249,6 @@ export type InitOptions = { */ offline?: boolean; - /** - * An object containing pre-fetched flags to be used instead of fetching them from the server. - * This is intended to be used with the Node-SDK getFlagsForBootstrap method. - */ - bootstrappedFlags?: FetchedFlags; - /** * Flag keys for which `isEnabled` should fallback to true * if SDK fails to fetch flags from Reflag servers. If a record @@ -316,6 +310,24 @@ export type InitOptions = { toolbar?: ToolbarOptions; }; +/** + * Init options for bootstrapped flags. + */ +export type InitOptionsBootstrapped = Omit< + InitOptions, + | "fallbackFlags" + | "timeoutMs" + | "staleWhileRevalidate" + | "staleTimeMs" + | "expireTimeMs" +> & { + bootstrappedFlags: FetchedFlags; +}; + +function isBootstrapped(opts: InitOptions): opts is InitOptionsBootstrapped { + return "bootstrappedFlags" in opts; +} + const defaultConfig: Config = { apiBaseUrl: API_BASE_URL, appBaseUrl: APP_BASE_URL, @@ -427,7 +439,8 @@ export class ReflagClient { sseBaseUrl: opts?.sseBaseUrl ?? defaultConfig.sseBaseUrl, enableTracking: opts?.enableTracking ?? defaultConfig.enableTracking, offline: opts?.offline ?? defaultConfig.offline, - bootstrapped: !!opts.bootstrappedFlags, + bootstrapped: + opts && "bootstrappedFlags" in opts && !!opts.bootstrappedFlags, initialized: false, }; @@ -451,14 +464,18 @@ export class ReflagClient { other: this.context.otherContext, }, this.logger, - { - bootstrappedFlags: opts.bootstrappedFlags, - expireTimeMs: opts.expireTimeMs, - staleTimeMs: opts.staleTimeMs, - fallbackFlags: opts.fallbackFlags, - timeoutMs: opts.timeoutMs, - offline: this.config.offline, - }, + isBootstrapped(opts) + ? { + bootstrappedFlags: opts.bootstrappedFlags, + offline: this.config.offline, + } + : { + expireTimeMs: opts.expireTimeMs, + staleTimeMs: opts.staleTimeMs, + timeoutMs: opts.timeoutMs, + fallbackFlags: opts.fallbackFlags, + offline: this.config.offline, + }, ); if ( diff --git a/packages/browser-sdk/src/index.ts b/packages/browser-sdk/src/index.ts index 6f988a6e..6b1d2681 100644 --- a/packages/browser-sdk/src/index.ts +++ b/packages/browser-sdk/src/index.ts @@ -3,6 +3,7 @@ export type { Flag, FlagRemoteConfig, InitOptions, + InitOptionsBootstrapped, ToolbarOptions, } from "./client"; export { ReflagClient } from "./client"; From ab86bd3cd4596a4058502093eebbc302ccfc0cd5 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Tue, 16 Sep 2025 11:06:57 +0200 Subject: [PATCH 07/58] test: fix --- packages/react-sdk/test/usage.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-sdk/test/usage.test.tsx b/packages/react-sdk/test/usage.test.tsx index 4ceaa78e..51bc8896 100644 --- a/packages/react-sdk/test/usage.test.tsx +++ b/packages/react-sdk/test/usage.test.tsx @@ -581,7 +581,7 @@ describe("", () => { otherContext: { test: "test", }, - flags: { + bootstrappedFlags: { abc: { key: "abc", isEnabled: true, @@ -650,7 +650,7 @@ describe("", () => { render(provider); - expect(initialize).toHaveBeenCalledWith(false); + expect(initialize).toHaveBeenCalledWith(); }); test("does not initialize when no flags are provided", () => { From 64db7eb1f584742fb4e6224fc416b80497f33ddd Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Tue, 16 Sep 2025 11:10:42 +0200 Subject: [PATCH 08/58] docs: browser SDK --- packages/browser-sdk/README.md | 62 ++++++++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index 56c47f44..c73d59d1 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -113,6 +113,68 @@ type Configuration = { }; ``` +## Server-side rendering and bootstrapping + +For server-side rendered applications, you can eliminate the initial network request by bootstrapping the client with pre-fetched flag data. + +### Init options bootstrapped + +```typescript +type Configuration = { + logger: console; // by default only logs warn/error, by passing `console` you'll log everything + apiBaseUrl?: "https://front.reflag.com"; + sseBaseUrl?: "https://livemessaging.bucket.co"; + feedback?: undefined; // See FEEDBACK.md + enableTracking?: true; // set to `false` to stop sending track events and user/company updates to Reflag servers. Useful when you're impersonating a user + offline?: boolean; // Use the SDK in offline mode. Offline mode is useful during testing and local development + bootstrappedFlags?: FetchedFlags; // Pre-fetched flags from server-side (see Server-side rendering section) +}; +``` + +### Using bootstrappedFlags + +Use the Node SDK's `getFlagsForBootstrap()` method to pre-fetch flags server-side, then pass them to the browser client: + +```typescript +// Server-side: Get flags using Node SDK +import { ReflagClient as ReflagNodeClient } from "@reflag/node-sdk"; + +const serverClient = new ReflagNodeClient({ secretKey: "your-secret-key" }); +await serverClient.initialize(); + +const { flags } = serverClient.getFlagsForBootstrap({ + user: { id: "user123" }, + company: { id: "company456" }, +}); + +// Pass flags data to client using your framework's preferred method +// or for example in a script tag +app.get("/", (req, res) => { + res.set("Content-Type", "text/html"); + res.send( + Buffer.from( + ` +
`, + ), + ); +}); + +// Client-side: Initialize with pre-fetched flags +import { ReflagClient } from "@reflag/browser-sdk"; + +const reflagClient = new ReflagClient({ + publishableKey: "your-publishable-key", + user: { id: "user123" }, + company: { id: "company456" }, + bootstrappedFlags: flags, // No network request needed +}); + +await reflagClient.initialize(); // Initializes all but flags +const { isEnabled } = reflagClient.getFlag("huddle"); +``` + +This eliminates loading states and improves performance by avoiding the initial flags API call. + ## Migrating from Bucket SDK If you have been using the Bucket SDKs, the following list will help you migrate to Reflag SDK: From e06b1b9da6ec4735bafb3883a6c8bf68524f3dbd Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Tue, 16 Sep 2025 13:17:40 +0200 Subject: [PATCH 09/58] fix: type and setContext --- packages/browser-sdk/src/client.ts | 2 +- packages/browser-sdk/src/flag/flags.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 0d2e36a7..497fe1e1 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -423,7 +423,7 @@ export class ReflagClient { /** * Create a new ReflagClient instance. */ - constructor(opts: InitOptions) { + constructor(opts: InitOptions | InitOptionsBootstrapped) { this.publishableKey = opts.publishableKey; this.logger = opts?.logger ?? loggerWithPrefix(quietConsoleLogger, "[Reflag]"); diff --git a/packages/browser-sdk/src/flag/flags.ts b/packages/browser-sdk/src/flag/flags.ts index e7ccadfe..ab8cba32 100644 --- a/packages/browser-sdk/src/flag/flags.ts +++ b/packages/browser-sdk/src/flag/flags.ts @@ -296,7 +296,7 @@ export class FlagsClient { async setContext(context: Context) { this.context = context; - await this.initialize(); + this.setFetchedFlags((await this.maybeFetchFlags()) || {}); } /** From 1ca0e7fc4f7d83bdf5d2751137e3d5e818c057a9 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Tue, 16 Sep 2025 13:18:09 +0200 Subject: [PATCH 10/58] feat: update ReflagProviders and add documentation --- packages/react-sdk/README.md | 174 +++++++++++++++++ packages/react-sdk/dev/plain/app.tsx | 36 ++++ packages/react-sdk/src/index.tsx | 249 ++++++++++++------------- packages/react-sdk/test/usage.test.tsx | 4 + 4 files changed, 331 insertions(+), 132 deletions(-) diff --git a/packages/react-sdk/README.md b/packages/react-sdk/README.md index e0596dd8..9d812686 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -167,6 +167,142 @@ const { Note that, similar to `isEnabled`, accessing `config` on the object returned by `useFlag()` automatically generates a `check` event. +## Server-side rendering and bootstrapping + +For server-side rendered applications, you can eliminate the initial network request by bootstrapping the client with pre-fetched flag data using the `ReflagBootstrappedProvider`. + +### Using `ReflagBootstrappedProvider` + +The `ReflagBootstrappedProvider` is designed for server-side rendering (SSR) scenarios where you've pre-fetched flags on the server and want to pass them to the client without an additional network request. + +```tsx +import { useState, useEffect } from "react"; +import { BootstrappedFlags } from "@reflag/react-sdk"; + +interface BootstrapData { + user: User; + flags: BootstrappedFlags; +} + +function useBootstrap() { + const [data, setData] = useState(null); + + useEffect(() => { + fetch("/bootstrap") + .then((res) => res.json()) + .then(setData); + }, []); + + return data; +} + +// Usage in your app +function App() { + const { user, flags } = useBootstrap(); + + return ( + + + + + + ); +} +``` + +### Server-side endpoint setup + +Create an endpoint that provides bootstrap data to your client application: + +```typescript +// server.js or your Express app +import { ReflagClient as ReflagNodeClient } from "@reflag/node-sdk"; + +const reflagClient = new ReflagNodeClient({ + secretKey: process.env.REFLAG_SECRET_KEY, +}); +await reflagClient.initialize(); + +app.get("/bootstrap", (req, res) => { + const user = getUser(req); // Get user from your auth system + const company = getCompany(req); // Get company from your auth system + + const flags = reflagClient.getFlagsForBootstrap({ + user: { id: user.id, email: user.email, role: user.role }, + company: { id: company.id, plan: company.plan }, + other: { source: "web" }, + }); + + res.status(200).json({ + user, + flags, + }); +}); +``` + +### Next.js SSR example + +For Next.js applications using server-side rendering, you can pre-fetch flags in `getServerSideProps`: + +```typescript +// pages/index.tsx +import { GetServerSideProps } from "next"; +import { ReflagClient as ReflagNodeClient } from "@reflag/node-sdk"; +import { ReflagBootstrappedProvider, BootstrappedFlags, useFlag } from "@reflag/react-sdk"; + +interface PageProps { + bootstrapData: BootstrappedFlags; +} + +export const getServerSideProps: GetServerSideProps = async (context) => { + const serverClient = new ReflagNodeClient({ + secretKey: process.env.REFLAG_SECRET_KEY + }); + await serverClient.initialize(); + + const user = await getUserFromSession(context.req); + const company = await getCompanyFromUser(user); + + const bootstrapData = serverClient.getFlagsForBootstrap({ + user: { id: user.id, email: user.email, role: user.role }, + company: { id: company.id, plan: company.plan }, + other: { page: "homepage" } + }); + + return { props: { bootstrapData } }; +}; + +export default function HomePage({ bootstrapData }: PageProps) { + return ( + + + + ); +} + +function HuddleFeature() { + const { isEnabled, track, config } = useFlag("huddle"); + + if (!isEnabled) return null; + + return ( +
+

Start a Huddle

+

{config.payload?.description ?? "Connect with your team instantly"}

+ +
+ ); +} +``` + +This approach eliminates loading states and improves performance by avoiding the initial flags API call. + ## `` component The `` initializes the Reflag SDK, fetches flags and starts listening for automated feedback survey events. The component can be configured using a number of props: @@ -227,6 +363,44 @@ The `` initializes the Reflag SDK, fetches flags and starts list - `toolbar`: Optional [configuration](https://docs.reflag.com/supported-languages/browser-sdk/globals#toolbaroptions) for the Reflag toolbar, - `feedback`: Optional configuration for feedback collection +## `` component + +The `` is a specialized version of the `ReflagProvider` that uses pre-fetched flag data instead of making network requests during initialization. This is ideal for server-side rendering scenarios. + +The component accepts the following props: + +- `flags`: Pre-fetched flags data of type `BootstrappedFlags` obtained from the Node SDK's `getFlagsForBootstrap()` method. This contains both the context (user, company, otherContext) and the flags data. +- All other props available in [`ReflagProvider`](#reflagprovider-component) are supported except `user`, `company`, and `otherContext` (which are extracted from `flags.context`). + +**Example:** + +```tsx +import { + ReflagBootstrappedProvider, + BootstrappedFlags, +} from "@reflag/react-sdk"; + +interface AppProps { + bootstrapData: BootstrappedFlags; +} + +function App({ bootstrapData }: AppProps) { + return ( + } + debug={process.env.NODE_ENV === "development"} + > + + + ); +} +``` + +> [!Note] +> When using `ReflagBootstrappedProvider`, the user, company, and otherContext are extracted from the `flags.context` property and don't need to be passed separately. + ## Hooks ### `useFlag()` diff --git a/packages/react-sdk/dev/plain/app.tsx b/packages/react-sdk/dev/plain/app.tsx index 728ba264..e55d52a2 100644 --- a/packages/react-sdk/dev/plain/app.tsx +++ b/packages/react-sdk/dev/plain/app.tsx @@ -10,6 +10,7 @@ import { useUpdateOtherContext, useUpdateUser, useClient, + ReflagBootstrappedProvider, } from "../../src"; // Extending the Flags interface to define the available features @@ -235,6 +236,7 @@ function CustomToolbar() { return (

Custom toolbar

+

This toolbar is static and won't update when flags are fetched.

    {Object.entries(client.getFlags()).map(([flagKey, feature]) => (
  • @@ -269,6 +271,40 @@ function CustomToolbar() { } export function App() { + const bootstrapped = new URLSearchParams(window.location.search).get( + "bootstrapped", + ); + + if (bootstrapped) { + return ( + + {!publishableKey && ( +
    + No publishable key set. Please set the VITE_PUBLISHABLE_KEY + environment variable. +
    + )} + +
    + ); + } + return ( ({ - isLoading: false, - provider: false, -}); +const ProviderContext = createContext(null); -/** - * Props for the ReflagProvider. - */ -export type ReflagProps = ReflagContext & - Omit & { - /** - * Children to be rendered. - */ - children?: ReactNode; - - /** - * Loading component to be rendered while features are loading. - */ - loadingComponent?: ReactNode; - - /** - * Whether to enable debug mode (optional). - */ +type UseReflagProviderOptions = { + config: Omit & { debug?: boolean; - - /** - * New ReflagClient constructor. - * - * @internal - */ newReflagClient?: ( ...args: ConstructorParameters ) => ReflagClient; }; + context?: ReflagContext; + bootstrappedFlags?: FetchedFlags; + isBootstrapped?: boolean; +}; /** - * Provider for the ReflagClient. + * Shared hook that handles the common logic for both ReflagProvider and ReflagBootstrappedProvider */ -export function ReflagProvider({ - children, - user, - company, - otherContext, - loadingComponent, - newReflagClient = (...args) => new ReflagClient(...args), - ...config -}: ReflagProps) { +function useReflagProvider({ + config, + context, + bootstrappedFlags, + isBootstrapped = false, +}: UseReflagProviderOptions): ProviderContextType { const [isLoading, setIsLoading] = useState(true); const clientRef = useRef(); const contextKeyRef = useRef(); - const featureContext = { user, company, otherContext }; - const contextKey = canonicalJSON({ config, featureContext }); + const { + newReflagClient = (...args) => new ReflagClient(...args), + debug, + ...initConfig + } = config; + + // Generate context key based to deduplicate initialization + const contextKey = useMemo(() => { + return canonicalJSON({ + config: initConfig, + flags: bootstrappedFlags, + ...context, + }); + }, [initConfig, context, bootstrappedFlags]); + + // Create base client options + const baseClientOptions = useMemo( + () => ({ + ...initConfig, + user: context?.user, + company: context?.company, + otherContext: context?.otherContext, + logger: debug ? console : undefined, + sdkVersion: SDK_VERSION, + }), + [initConfig, context, debug], + ); useEffect(() => { - // useEffect will run twice in development mode - // This is a workaround to prevent re-initialization + // For bootstrapped provider, don't initialize if flags are not provided + if (isBootstrapped && !bootstrappedFlags) { + return; + } + + // Prevent re-initialization if the context key is the same if (contextKeyRef.current === contextKey) { return; } contextKeyRef.current = contextKey; - // on update of contextKey and on unmount + // Stop the client if it exists if (clientRef.current) { void clientRef.current.stop(); } setIsLoading(true); - const client = newReflagClient({ - ...config, - user, - company, - otherContext, - - logger: config.debug ? console : undefined, - sdkVersion: SDK_VERSION, - }); + // Add bootstrapped flags if this is a bootstrapped provider + const clientOptions = bootstrappedFlags + ? { ...baseClientOptions, bootstrappedFlags } + : baseClientOptions; + const client = newReflagClient(clientOptions); clientRef.current = client; client @@ -218,21 +219,57 @@ export function ReflagProvider({ .finally(() => { setIsLoading(false); }); - // eslint-disable-next-line react-hooks/exhaustive-deps -- should only run once - }, [contextKey]); - - const context: ProviderContextType = useMemo( + }, [ + contextKey, + baseClientOptions, + bootstrappedFlags, + newReflagClient, + isBootstrapped, + ]); + + return useMemo( () => ({ - isLoading: isLoading, + isLoading, client: clientRef.current, - provider: true, }), [isLoading], ); +} + +type ReflagPropsBase = { + children?: ReactNode; + loadingComponent?: ReactNode; + debug?: boolean; + newReflagClient?: ( + ...args: ConstructorParameters + ) => ReflagClient; +}; + +/** + * Props for the ReflagProvider. + */ +export type ReflagProps = InitOptions & ReflagContext & ReflagPropsBase; + +/** + * Provider for the ReflagClient. + */ +export function ReflagProvider({ + children, + user, + company, + otherContext, + loadingComponent, + newReflagClient = (...args) => new ReflagClient(...args), + ...config +}: ReflagProps) { + const context = useReflagProvider({ + config: { ...config, newReflagClient }, + context: { user, company, otherContext }, + }); return ( - {isLoading && typeof loadingComponent !== "undefined" + {context.isLoading && typeof loadingComponent !== "undefined" ? loadingComponent : children} @@ -243,14 +280,15 @@ export function ReflagProvider({ * Props for the ReflagBootstrappedProvider. */ export type ReflagBootstrappedProps = Omit< - ReflagProps, - "user" | "company" | "otherContext" -> & { - /** - * Pre-fetched flags to be used instead of fetching them from the server. - */ - flags?: BootstrappedFlags; -}; + InitOptionsBootstrapped, + "bootstrappedFlags" +> & + ReflagPropsBase & { + /** + * Pre-fetched flags to be used instead of fetching them from the server. + */ + flags?: BootstrappedFlags; + }; /** * Bootstrapped Provider for the ReflagClient using pre-fetched flags. @@ -262,70 +300,16 @@ export function ReflagBootstrappedProvider({ newReflagClient = (...args) => new ReflagClient(...args), ...config }: ReflagBootstrappedProps) { - const [featuresLoading, setFlagsLoading] = useState(true); - - const clientRef = useRef(); - const contextKeyRef = useRef(); - - const contextKey = canonicalJSON({ - config, - flags: flags?.flags, - ...flags?.context, + const context = useReflagProvider({ + config: { ...config, newReflagClient }, + context: flags?.context, + bootstrappedFlags: flags?.flags, + isBootstrapped: true, }); - useEffect(() => { - // todo: will be stuck in loading state if flags fail to load - if (!flags) { - return; - } - // useEffect will run twice in development mode - // This is a workaround to prevent re-initialization - if (contextKeyRef.current === contextKey) { - return; - } - contextKeyRef.current = contextKey; - - // on update of contextKey and on unmount - if (clientRef.current) { - void clientRef.current.stop(); - } - - const client = newReflagClient({ - ...config, - bootstrappedFlags: flags.flags, - user: flags.context.user, - company: flags.context.company, - otherContext: flags.context.otherContext, - - logger: config.debug ? console : undefined, - sdkVersion: SDK_VERSION, - }); - - clientRef.current = client; - - client - .initialize() - .catch((e) => { - client.logger.error("failed to initialize client", e); - }) - .finally(() => { - setFlagsLoading(false); - }); - // eslint-disable-next-line react-hooks/exhaustive-deps -- should only run once - }, [contextKey]); - - const context: ProviderContextType = useMemo( - () => ({ - isLoading: featuresLoading, - client: clientRef.current, - provider: true, - }), - [featuresLoading], - ); - return ( - {featuresLoading && typeof loadingComponent !== "undefined" + {context.isLoading && typeof loadingComponent !== "undefined" ? loadingComponent : children} @@ -357,7 +341,8 @@ export function useFeature(key: TKey) { */ export function useFlag(key: TKey): TypedFlags[TKey] { const client = useClient(); - const { isLoading } = useContext(ProviderContext); + const context = useContext(ProviderContext); + const isLoading = context?.isLoading ?? true; const track = () => client?.track(key); const requestFeedback = (opts: RequestFeedbackOptions) => @@ -522,12 +507,12 @@ export function useUpdateOtherContext() { * ``` */ export function useClient() { - const { client, provider } = useContext(ProviderContext); - if (!provider) { + const context = useContext(ProviderContext); + if (!context) { throw new Error( "ReflagProvider is missing. Please ensure your component is wrapped with a ReflagProvider.", ); } - return client; + return context.client; } diff --git a/packages/react-sdk/test/usage.test.tsx b/packages/react-sdk/test/usage.test.tsx index 51bc8896..8792937c 100644 --- a/packages/react-sdk/test/usage.test.tsx +++ b/packages/react-sdk/test/usage.test.tsx @@ -174,6 +174,7 @@ describe("", () => { const newReflagClient = vi.fn().mockReturnValue({ initialize: vi.fn().mockResolvedValue(undefined), on, + stop: vi.fn(), }); const provider = getProvider({ @@ -522,6 +523,7 @@ describe("", () => { const newReflagClient = vi.fn().mockReturnValue({ initialize: vi.fn().mockResolvedValue(undefined), on, + stop: vi.fn(), }); const bootstrapFlags: BootstrappedFlags = { @@ -627,6 +629,7 @@ describe("", () => { const newReflagClient = vi.fn().mockReturnValue({ initialize, on: vi.fn(), + stop: vi.fn(), }); const bootstrapFlags: BootstrappedFlags = { @@ -659,6 +662,7 @@ describe("", () => { const newReflagClient = vi.fn().mockReturnValue({ initialize, on: vi.fn(), + stop: vi.fn(), }); const provider = getBootstrapProvider(undefined, { From 9fd6c00ee99a1f1150d702aa329670eed72c3c90 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Tue, 16 Sep 2025 13:21:50 +0200 Subject: [PATCH 11/58] docs: move bootstrapFlags to below remoteConfig --- packages/browser-sdk/README.md | 124 ++++++++++++++++----------------- 1 file changed, 62 insertions(+), 62 deletions(-) diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index c73d59d1..a3dcbcda 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -113,68 +113,6 @@ type Configuration = { }; ``` -## Server-side rendering and bootstrapping - -For server-side rendered applications, you can eliminate the initial network request by bootstrapping the client with pre-fetched flag data. - -### Init options bootstrapped - -```typescript -type Configuration = { - logger: console; // by default only logs warn/error, by passing `console` you'll log everything - apiBaseUrl?: "https://front.reflag.com"; - sseBaseUrl?: "https://livemessaging.bucket.co"; - feedback?: undefined; // See FEEDBACK.md - enableTracking?: true; // set to `false` to stop sending track events and user/company updates to Reflag servers. Useful when you're impersonating a user - offline?: boolean; // Use the SDK in offline mode. Offline mode is useful during testing and local development - bootstrappedFlags?: FetchedFlags; // Pre-fetched flags from server-side (see Server-side rendering section) -}; -``` - -### Using bootstrappedFlags - -Use the Node SDK's `getFlagsForBootstrap()` method to pre-fetch flags server-side, then pass them to the browser client: - -```typescript -// Server-side: Get flags using Node SDK -import { ReflagClient as ReflagNodeClient } from "@reflag/node-sdk"; - -const serverClient = new ReflagNodeClient({ secretKey: "your-secret-key" }); -await serverClient.initialize(); - -const { flags } = serverClient.getFlagsForBootstrap({ - user: { id: "user123" }, - company: { id: "company456" }, -}); - -// Pass flags data to client using your framework's preferred method -// or for example in a script tag -app.get("/", (req, res) => { - res.set("Content-Type", "text/html"); - res.send( - Buffer.from( - ` -
    `, - ), - ); -}); - -// Client-side: Initialize with pre-fetched flags -import { ReflagClient } from "@reflag/browser-sdk"; - -const reflagClient = new ReflagClient({ - publishableKey: "your-publishable-key", - user: { id: "user123" }, - company: { id: "company456" }, - bootstrappedFlags: flags, // No network request needed -}); - -await reflagClient.initialize(); // Initializes all but flags -const { isEnabled } = reflagClient.getFlag("huddle"); -``` - -This eliminates loading states and improves performance by avoiding the initial flags API call. - ## Migrating from Bucket SDK If you have been using the Bucket SDKs, the following list will help you migrate to Reflag SDK: @@ -289,6 +227,66 @@ const flags = reflagClient.getFlags(); Just as `isEnabled`, accessing `config` on the object returned by `getFlags` does not automatically generate a `check` event, contrary to the `config` property on the object returned by `getFlag`. +## Server-side rendering and bootstrapping + +For server-side rendered applications, you can eliminate the initial network request by bootstrapping the client with pre-fetched flag data. + +### Init options bootstrapped + +```typescript +type Configuration = { + logger: console; // by default only logs warn/error, by passing `console` you'll log everything + apiBaseUrl?: "https://front.reflag.com"; + sseBaseUrl?: "https://livemessaging.bucket.co"; + feedback?: undefined; // See FEEDBACK.md + enableTracking?: true; // set to `false` to stop sending track events and user/company updates to Reflag servers. Useful when you're impersonating a user + offline?: boolean; // Use the SDK in offline mode. Offline mode is useful during testing and local development + bootstrappedFlags?: FetchedFlags; // Pre-fetched flags from server-side (see Server-side rendering section) +}; +``` + +### Using bootstrappedFlags + +Use the Node SDK's `getFlagsForBootstrap()` method to pre-fetch flags server-side, then pass them to the browser client: + +```typescript +// Server-side: Get flags using Node SDK +import { ReflagClient as ReflagNodeClient } from "@reflag/node-sdk"; + +const serverClient = new ReflagNodeClient({ secretKey: "your-secret-key" }); +await serverClient.initialize(); + +const { flags } = serverClient.getFlagsForBootstrap({ + user: { id: "user123" }, + company: { id: "company456" }, +}); + +// Pass flags data to client using your framework's preferred method +// or for example in a script tag +app.get("/", (req, res) => { + res.set("Content-Type", "text/html"); + res.send( + Buffer.from( + ` +
    `, + ), + ); +}); + +// Client-side: Initialize with pre-fetched flags +import { ReflagClient } from "@reflag/browser-sdk"; + +const reflagClient = new ReflagClient({ + publishableKey: "your-publishable-key", + user: { id: "user123" }, + company: { id: "company456" }, + bootstrappedFlags: flags, // No network request needed +}); + +await reflagClient.initialize(); // Initializes all but flags +const { isEnabled } = reflagClient.getFlag("huddle"); +``` + ## Updating user/company/other context Attributes given for the user/company/other context in the ReflagClient constructor can be updated for use in flag targeting evaluation with the `updateUser()`, `updateCompany()` and `updateOtherContext()` methods. @@ -306,6 +304,8 @@ await reflagClient.updateUser({ voiceHuddleOptIn: (!isEnabled).toString() }); > [!NOTE] > `user`/`company` attributes are also stored remotely on the Reflag servers and will automatically be used to evaluate flag targeting if the page is refreshed. +This eliminates loading states and improves performance by avoiding the initial flags API call. + ## Toolbar The Reflag Toolbar is great for toggling flags on/off for yourself to ensure that everything works both when a flag is on and when it's off. From bb6e060455e283e11af8e87467e5342c692e0320 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Tue, 16 Sep 2025 14:39:14 +0200 Subject: [PATCH 12/58] fix: update canonical-json to match vue library --- packages/react-sdk/package.json | 2 +- packages/react-sdk/src/index.tsx | 21 ++++++++++++++++----- yarn.lock | 2 +- 3 files changed, 18 insertions(+), 7 deletions(-) diff --git a/packages/react-sdk/package.json b/packages/react-sdk/package.json index 9fcf9453..df72301c 100644 --- a/packages/react-sdk/package.json +++ b/packages/react-sdk/package.json @@ -35,7 +35,7 @@ }, "dependencies": { "@reflag/browser-sdk": "1.2.0", - "canonical-json": "^0.0.4", + "canonical-json": "^0.2.0", "rollup": "^4.2.0" }, "peerDependencies": { diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index 28384de4..68d7cbe2 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -123,6 +123,15 @@ export type FlagKey = keyof TypedFlags; const SDK_VERSION = `react-sdk/${version}`; +function removeUndefined(obj: Record) { + const t = obj; + for (const v in t) { + if (typeof t[v] == "object") removeUndefined(t[v]); + else if (t[v] == undefined) delete t[v]; + } + return t; +} + type ProviderContextType = { isLoading: boolean; client?: ReflagClient; @@ -164,11 +173,13 @@ function useReflagProvider({ // Generate context key based to deduplicate initialization const contextKey = useMemo(() => { - return canonicalJSON({ - config: initConfig, - flags: bootstrappedFlags, - ...context, - }); + return canonicalJSON( + removeUndefined({ + ...initConfig, + ...context, + flags: bootstrappedFlags ?? null, + }), + ); }, [initConfig, context, bootstrappedFlags]); // Create base client options diff --git a/yarn.lock b/yarn.lock index 6221c220..8ded1dbf 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3136,7 +3136,7 @@ __metadata: "@types/react": "npm:^18.3.2" "@types/react-dom": "npm:^18.3.0" "@types/webpack": "npm:^5.28.5" - canonical-json: "npm:^0.0.4" + canonical-json: "npm:^0.2.0" eslint: "npm:^9.21.0" jsdom: "npm:^24.1.0" msw: "npm:^2.3.5" From 8293c429a1497e69c55b35b86b3fb6ad17b32a32 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Tue, 16 Sep 2025 15:06:00 +0200 Subject: [PATCH 13/58] fix: build --- packages/react-sdk/src/index.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index 68d7cbe2..e131bda9 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -247,7 +247,11 @@ function useReflagProvider({ ); } -type ReflagPropsBase = { +/** + * Base props for the ReflagProvider and ReflagBootstrappedProvider. + * @internal + */ +export type ReflagPropsBase = { children?: ReactNode; loadingComponent?: ReactNode; debug?: boolean; From e7f849987ee0eb099f2cdbb522dbce4fcb403682 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Tue, 16 Sep 2025 15:28:41 +0200 Subject: [PATCH 14/58] test: fix --- packages/browser-sdk/src/flag/flags.ts | 1 + packages/browser-sdk/test/flags.test.ts | 6 +++--- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/packages/browser-sdk/src/flag/flags.ts b/packages/browser-sdk/src/flag/flags.ts index ab8cba32..071bdf9b 100644 --- a/packages/browser-sdk/src/flag/flags.ts +++ b/packages/browser-sdk/src/flag/flags.ts @@ -289,6 +289,7 @@ export class FlagsClient { async initialize() { if (this.initialized) { this.logger.error("flags client already initialized"); + return; } this.setFetchedFlags((await this.maybeFetchFlags()) || {}); this.initialized = true; diff --git a/packages/browser-sdk/test/flags.test.ts b/packages/browser-sdk/test/flags.test.ts index 9e7004e8..e2e165f9 100644 --- a/packages/browser-sdk/test/flags.test.ts +++ b/packages/browser-sdk/test/flags.test.ts @@ -108,7 +108,7 @@ describe("FlagsClient", () => { expect(testLogger.warn).toHaveBeenCalledTimes(1); vi.advanceTimersByTime(60 * 1000); await flagsClient.initialize(); - expect(testLogger.warn).toHaveBeenCalledTimes(2); + expect(testLogger.error).toHaveBeenCalledTimes(1); }); test("ignores undefined context", async () => { @@ -508,14 +508,14 @@ describe("FlagsClient", () => { updateTriggered = true; }); - // Trigger the flags updated event by setting context (which should not fetch since already initialized) + // Trigger the flags updated event by setting context (which should still fetch) await flagsClient.setContext({ user: { id: "456" }, company: { id: "789" }, other: { eventId: "other-conference" }, }); - expect(updateTriggered).toBe(false); // No update since context change doesn't affect pre-fetched flags + expect(updateTriggered).toBe(true); }); test("should work with fallback flags when initialization fails", async () => { From c452029192479b49d36d28f278d2d4b15683e52e Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Tue, 16 Sep 2025 15:44:22 +0200 Subject: [PATCH 15/58] refactor: cleaned up types --- packages/browser-sdk/src/client.ts | 21 ++------------------- packages/browser-sdk/src/context.ts | 6 ++++-- packages/react-sdk/src/index.tsx | 2 +- 3 files changed, 7 insertions(+), 22 deletions(-) diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 497fe1e1..6328661d 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -16,7 +16,7 @@ import { } from "./flag/flags"; import { ToolbarPosition } from "./ui/types"; import { API_BASE_URL, APP_BASE_URL, SSE_REALTIME_BASE_URL } from "./config"; -import { CompanyContext, ReflagContext, UserContext } from "./context"; +import { ReflagContext } from "./context"; import { HookArgs, HooksManager } from "./hooksManager"; import { HttpClient } from "./httpClient"; import { Logger, loggerWithPrefix, quietConsoleLogger } from "./logger"; @@ -201,29 +201,12 @@ export type FlagDefinitions = Readonly>; /** * ReflagClient initialization options. */ -export type InitOptions = { +export type InitOptions = ReflagContext & { /** * Publishable key for authentication */ publishableKey: string; - /** - * User related context. If you provide `id` Reflag will enrich the evaluation context with - * user attributes on Reflag servers. - */ - user?: UserContext; - - /** - * Company related context. If you provide `id` Reflag will enrich the evaluation context with - * company attributes on Reflag servers. - */ - company?: CompanyContext; - - /** - * Context not related to users or companies - */ - otherContext?: Record; - /** * You can provide a logger to see the logs of the network calls. * This is undefined by default. diff --git a/packages/browser-sdk/src/context.ts b/packages/browser-sdk/src/context.ts index 0322aabc..f22daa6a 100644 --- a/packages/browser-sdk/src/context.ts +++ b/packages/browser-sdk/src/context.ts @@ -43,12 +43,14 @@ export interface UserContext { export interface ReflagContext { /** - * Company related context + * Company related context. If you provide `id` Reflag will enrich the evaluation context with + * company attributes on Reflag servers. */ company?: CompanyContext; /** - * User related context + * User related context. If you provide `id` Reflag will enrich the evaluation context with + * user attributes on Reflag servers. */ user?: UserContext; diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index e131bda9..409d34b9 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -140,7 +140,7 @@ type ProviderContextType = { const ProviderContext = createContext(null); type UseReflagProviderOptions = { - config: Omit & { + config: Omit & { debug?: boolean; newReflagClient?: ( ...args: ConstructorParameters From 0749050b0f951f57572fd4aa1bf2db2393f2b84e Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Tue, 16 Sep 2025 15:52:36 +0200 Subject: [PATCH 16/58] fix: remove type keys --- packages/react-sdk/src/index.tsx | 42 ++++++++++++++++---------------- 1 file changed, 21 insertions(+), 21 deletions(-) diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index 409d34b9..c3eed481 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -139,13 +139,25 @@ type ProviderContextType = { const ProviderContext = createContext(null); +/** + * Base props for the ReflagProvider and ReflagBootstrappedProvider. + * @internal + */ +export type ReflagPropsBase = { + children?: ReactNode; + loadingComponent?: ReactNode; + debug?: boolean; + newReflagClient?: ( + ...args: ConstructorParameters + ) => ReflagClient; +}; + +/** + * Options for the useReflagProvider hook. + * @internal + */ type UseReflagProviderOptions = { - config: Omit & { - debug?: boolean; - newReflagClient?: ( - ...args: ConstructorParameters - ) => ReflagClient; - }; + config: Omit & ReflagPropsBase; context?: ReflagContext; bootstrappedFlags?: FetchedFlags; isBootstrapped?: boolean; @@ -153,6 +165,7 @@ type UseReflagProviderOptions = { /** * Shared hook that handles the common logic for both ReflagProvider and ReflagBootstrappedProvider + * @internal */ function useReflagProvider({ config, @@ -247,23 +260,10 @@ function useReflagProvider({ ); } -/** - * Base props for the ReflagProvider and ReflagBootstrappedProvider. - * @internal - */ -export type ReflagPropsBase = { - children?: ReactNode; - loadingComponent?: ReactNode; - debug?: boolean; - newReflagClient?: ( - ...args: ConstructorParameters - ) => ReflagClient; -}; - /** * Props for the ReflagProvider. */ -export type ReflagProps = InitOptions & ReflagContext & ReflagPropsBase; +export type ReflagProps = InitOptions & ReflagPropsBase; /** * Provider for the ReflagClient. @@ -296,7 +296,7 @@ export function ReflagProvider({ */ export type ReflagBootstrappedProps = Omit< InitOptionsBootstrapped, - "bootstrappedFlags" + "bootstrappedFlags" | keyof ReflagContext > & ReflagPropsBase & { /** From b6c69dda3fcf6c4f289a0729391acb4f286bdf99 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Tue, 16 Sep 2025 15:57:49 +0200 Subject: [PATCH 17/58] docs: standardize docs --- packages/browser-sdk/README.md | 8 ++++---- packages/node-sdk/README.md | 19 ++++++++++--------- packages/react-sdk/README.md | 11 ++++++----- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index a3dcbcda..3913140c 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -257,8 +257,8 @@ const serverClient = new ReflagNodeClient({ secretKey: "your-secret-key" }); await serverClient.initialize(); const { flags } = serverClient.getFlagsForBootstrap({ - user: { id: "user123" }, - company: { id: "company456" }, + user: { id: "user123", name: "John Doe", email: "john@acme.com" }, + company: { id: "company456", name: "Acme Inc", plan: "enterprise" }, }); // Pass flags data to client using your framework's preferred method @@ -278,8 +278,8 @@ import { ReflagClient } from "@reflag/browser-sdk"; const reflagClient = new ReflagClient({ publishableKey: "your-publishable-key", - user: { id: "user123" }, - company: { id: "company456" }, + user: { id: "user123", name: "John Doe", email: "john@acme.com" }, + company: { id: "company456", name: "Acme Inc", plan: "enterprise" }, bootstrappedFlags: flags, // No network request needed }); diff --git a/packages/node-sdk/README.md b/packages/node-sdk/README.md index 44e587f1..1f6dcb39 100644 --- a/packages/node-sdk/README.md +++ b/packages/node-sdk/README.md @@ -217,17 +217,18 @@ await client.initialize(); // Get flags for bootstrapping with full context const { context, flags } = client.getFlagsForBootstrap({ user: { - id: "john_doe", + id: "user123", name: "John Doe", email: "john@acme.com", }, company: { - id: "acme_inc", - name: "Acme, Inc.", + id: "company456", + name: "Acme Inc", + plan: "enterprise", }, other: { - location: "US", - platform: "web", + source: "web", + platform: "desktop", }, }); @@ -239,8 +240,8 @@ console.log(flags); // "key": "huddle", // "isEnabled": true, // "config": { -// "key": "pro", -// "payload": { "maxParticipants": 10 }, +// "key": "enhanced", +// "payload": { "maxParticipants": 50, "videoQuality": "hd" }, // } // } // } @@ -250,8 +251,8 @@ You can also use a bound client for simpler API: ```typescript const boundClient = client.bindClient({ - user: { id: "john_doe" }, - company: { id: "acme_inc" }, + user: { id: "user123", name: "John Doe", email: "john@acme.com" }, + company: { id: "company456", name: "Acme Inc", plan: "enterprise" }, }); const { context, flags } = boundClient.getFlagsForBootstrap(); diff --git a/packages/react-sdk/README.md b/packages/react-sdk/README.md index 9d812686..2ebf46b1 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -231,8 +231,8 @@ app.get("/bootstrap", (req, res) => { const company = getCompany(req); // Get company from your auth system const flags = reflagClient.getFlagsForBootstrap({ - user: { id: user.id, email: user.email, role: user.role }, - company: { id: company.id, plan: company.plan }, + user: { id: "user123", name: "John Doe", email: "john@acme.com" }, + company: { id: "company456", name: "Acme Inc", plan: "enterprise" }, other: { source: "web" }, }); @@ -267,8 +267,8 @@ export const getServerSideProps: GetServerSideProps = async (context) => { const company = await getCompanyFromUser(user); const bootstrapData = serverClient.getFlagsForBootstrap({ - user: { id: user.id, email: user.email, role: user.role }, - company: { id: company.id, plan: company.plan }, + user: { id: "user123", name: "John Doe", email: "john@acme.com" }, + company: { id: "company456", name: "Acme Inc", plan: "enterprise" }, other: { page: "homepage" } }); @@ -294,7 +294,8 @@ function HuddleFeature() { return (

    Start a Huddle

    -

    {config.payload?.description ?? "Connect with your team instantly"}

    +

    Max participants: {config.payload?.maxParticipants ?? 10}

    +

    Video quality: {config.payload?.videoQuality ?? "standard"}

    ); From d70ca0dd2a13d82b21085f79356a3a3fb738686f Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Tue, 16 Sep 2025 16:00:30 +0200 Subject: [PATCH 18/58] docs: key benefits added --- packages/browser-sdk/README.md | 7 +++++++ packages/node-sdk/README.md | 14 +++++++------- packages/react-sdk/README.md | 7 +++++++ 3 files changed, 21 insertions(+), 7 deletions(-) diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index 3913140c..cc290e55 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -231,6 +231,13 @@ generate a `check` event, contrary to the `config` property on the object return For server-side rendered applications, you can eliminate the initial network request by bootstrapping the client with pre-fetched flag data. +### Key benefits + +- **Faster initial rendering**: No need to wait for flag fetch requests +- **Better SEO**: Flags are available immediately during SSR +- **Reduced server load**: Flags can be cached and reused across requests +- **Offline capability**: Works without an internet connection when flags are pre-fetched + ### Init options bootstrapped ```typescript diff --git a/packages/node-sdk/README.md b/packages/node-sdk/README.md index 1f6dcb39..abc77f57 100644 --- a/packages/node-sdk/README.md +++ b/packages/node-sdk/README.md @@ -210,6 +210,13 @@ const flagDefs = await client.getFlagDefinitions(); The `getFlagsForBootstrap()` method is designed for server-side rendering (SSR) scenarios where you need to pass flag data to client-side applications. This method returns raw flag data without wrapper functions, making it suitable for serialization and client-side hydration. +### Key benefits + +- **Faster initial rendering**: No need to wait for flag fetch requests +- **Better SEO**: Flags are available immediately during SSR +- **Reduced server load**: Flags can be cached and reused across requests +- **Offline capability**: Works without an internet connection when flags are pre-fetched + ```typescript const client = new ReflagClient(); await client.initialize(); @@ -264,13 +271,6 @@ const { context, flags } = boundClient.getFlagsForBootstrap(); - **Context included**: Returns both the evaluated flags and the context used for evaluation - **Bootstrapping focus**: Designed specifically for passing data to client-side applications -### Common use cases - -1. **Server-side rendering**: Pass initial flag state to React/Vue/Angular applications -2. **API responses**: Include flag data in JSON API responses for client consumption -3. **Static site generation**: Evaluate flags at build time for static sites -4. **Mobile app configuration**: Send flag configuration to mobile applications on startup - ## Edge-runtimes like Cloudflare Workers To use the Reflag NodeSDK with Cloudflare workers, set the `node_compat` flag [in your wrangler file](https://developers.cloudflare.com/workers/runtime-apis/nodejs/#get-started). diff --git a/packages/react-sdk/README.md b/packages/react-sdk/README.md index 2ebf46b1..d9666cab 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -175,6 +175,13 @@ For server-side rendered applications, you can eliminate the initial network req The `ReflagBootstrappedProvider` is designed for server-side rendering (SSR) scenarios where you've pre-fetched flags on the server and want to pass them to the client without an additional network request. +### Key benefits + +- **Faster initial rendering**: No need to wait for flag fetch requests +- **Better SEO**: Flags are available immediately during SSR +- **Reduced server load**: Flags can be cached and reused across requests +- **Offline capability**: Works without an internet connection when flags are pre-fetched + ```tsx import { useState, useEffect } from "react"; import { BootstrappedFlags } from "@reflag/react-sdk"; From 83625d869380085a2fd349d6e06a99397ebf0c4c Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Tue, 16 Sep 2025 16:15:24 +0200 Subject: [PATCH 19/58] feat: added useIsLoading hook similar to vue --- packages/react-sdk/src/index.tsx | 33 +++++++++++++++++++++++--------- 1 file changed, 24 insertions(+), 9 deletions(-) diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index c3eed481..9b371d57 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -356,8 +356,7 @@ export function useFeature(key: TKey) { */ export function useFlag(key: TKey): TypedFlags[TKey] { const client = useClient(); - const context = useContext(ProviderContext); - const isLoading = context?.isLoading ?? true; + const isLoading = useIsLoading(); const track = () => client?.track(key); const requestFeedback = (opts: RequestFeedbackOptions) => @@ -507,6 +506,28 @@ export function useUpdateOtherContext() { client?.updateOtherContext(opts); } +/** + * Returns the current `ReflagProvider` context. + * @internal + */ +function useSafeContext() { + const ctx = useContext(ProviderContext); + if (!ctx) { + throw new Error( + `ReflagProvider is missing. Please ensure your component is wrapped with a ReflagProvider.`, + ); + } + return ctx; +} + +/** + * Returns a boolean indicating if the Reflag client is loading. + */ +export function useIsLoading() { + const context = useSafeContext(); + return context.isLoading; +} + /** * Returns the current `ReflagClient` used by the `ReflagProvider`. * @@ -522,12 +543,6 @@ export function useUpdateOtherContext() { * ``` */ export function useClient() { - const context = useContext(ProviderContext); - if (!context) { - throw new Error( - "ReflagProvider is missing. Please ensure your component is wrapped with a ReflagProvider.", - ); - } - + const context = useSafeContext(); return context.client; } From 3e119875fb7629ba0c1566b184cb1f6afdd60500 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Tue, 16 Sep 2025 16:15:30 +0200 Subject: [PATCH 20/58] docs: fix order --- packages/browser-sdk/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index cc290e55..5cdcb4ec 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -294,6 +294,8 @@ await reflagClient.initialize(); // Initializes all but flags const { isEnabled } = reflagClient.getFlag("huddle"); ``` +This eliminates loading states and improves performance by avoiding the initial flags API call. + ## Updating user/company/other context Attributes given for the user/company/other context in the ReflagClient constructor can be updated for use in flag targeting evaluation with the `updateUser()`, `updateCompany()` and `updateOtherContext()` methods. @@ -311,8 +313,6 @@ await reflagClient.updateUser({ voiceHuddleOptIn: (!isEnabled).toString() }); > [!NOTE] > `user`/`company` attributes are also stored remotely on the Reflag servers and will automatically be used to evaluate flag targeting if the page is refreshed. -This eliminates loading states and improves performance by avoiding the initial flags API call. - ## Toolbar The Reflag Toolbar is great for toggling flags on/off for yourself to ensure that everything works both when a flag is on and when it's off. From 29e774e93a65d97b643a5ee94f8d806ef5f1f7e9 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Tue, 16 Sep 2025 16:15:42 +0200 Subject: [PATCH 21/58] feat: update vue to new pattern --- packages/vue-sdk/README.md | 78 +++++++++++ packages/vue-sdk/dev/plain/App.vue | 59 +++++++- .../dev/plain/components/FlagsList.vue | 127 ++++++++++++++++++ .../src/ReflagBootstrappedProvider.vue | 31 +++++ packages/vue-sdk/src/ReflagProvider.vue | 62 ++------- packages/vue-sdk/src/hooks.ts | 39 +++--- packages/vue-sdk/src/index.ts | 20 ++- packages/vue-sdk/src/types.ts | 53 ++++++-- packages/vue-sdk/src/useReflagProvider.ts | 99 ++++++++++++++ packages/vue-sdk/test/usage.test.ts | 103 ++++++++++++-- 10 files changed, 578 insertions(+), 93 deletions(-) create mode 100644 packages/vue-sdk/dev/plain/components/FlagsList.vue create mode 100644 packages/vue-sdk/src/ReflagBootstrappedProvider.vue create mode 100644 packages/vue-sdk/src/useReflagProvider.ts diff --git a/packages/vue-sdk/README.md b/packages/vue-sdk/README.md index 9373eb79..050745f5 100644 --- a/packages/vue-sdk/README.md +++ b/packages/vue-sdk/README.md @@ -176,6 +176,84 @@ ReflagProvider lets you define a template to be shown while ReflagProvider is in If you want more control over loading screens, `useIsLoading()` returns a `Ref` which you can use to customize the loading experience. +## `` component + +The `` component is a specialized version of `ReflagProvider` designed for server-side rendering and preloaded flag scenarios. Instead of fetching flags from the server, it uses pre-fetched flags to initialize the SDK, resulting in faster initial page loads and better SSR compatibility. + +### Key benefits + +- **Faster initial rendering**: No need to wait for flag fetch requests +- **Better SEO**: Flags are available immediately during SSR +- **Reduced server load**: Flags can be cached and reused across requests +- **Offline capability**: Works without an internet connection when flags are pre-fetched + +### Usage + +```vue + + + +``` + +### Getting bootstrapped flags + +You'll typically generate the `bootstrappedFlags` object on your server using the Node.js SDK or by fetching from the Reflag API. Here's an example using the Node.js SDK: + +```js +// server.js (Node.js/SSR) +import { ReflagClient } from "@reflag/node-sdk"; + +const client = new ReflagClient({ + secretKey: "your-secret-key", // Use secret key on server +}); +await client.initialize(); + +// Fetch flags for specific context +const context = { + user: { id: "user123", name: "John Doe", email: "john@acme.com" }, + company: { id: "company456", name: "Acme Inc", plan: "enterprise" }, +}; + +const bootstrappedFlags = client.getFlagsForBootstrap(context); + +// Pass to your Vue app +``` + +### Props + +`ReflagBootstrappedProvider` accepts all the same props as `ReflagProvider` except: + +- `timeoutMs`, `staleWhileRevalidate`, `staleTimeMs`, `expireTimeMs` are not applicable since no requests to fetch flags are made +- `flags`: The pre-fetched flags object containing context and flag data + +If the `flags` prop is not provided or is undefined, the provider will not initialize the client and will render in a non-loading state. + ## Hooks ### `useFlag()` diff --git a/packages/vue-sdk/dev/plain/App.vue b/packages/vue-sdk/dev/plain/App.vue index 009d60d4..84b8f669 100644 --- a/packages/vue-sdk/dev/plain/App.vue +++ b/packages/vue-sdk/dev/plain/App.vue @@ -1,30 +1,80 @@ diff --git a/packages/vue-sdk/dev/plain/components/FlagsList.vue b/packages/vue-sdk/dev/plain/components/FlagsList.vue new file mode 100644 index 00000000..ac196602 --- /dev/null +++ b/packages/vue-sdk/dev/plain/components/FlagsList.vue @@ -0,0 +1,127 @@ + + + diff --git a/packages/vue-sdk/src/ReflagBootstrappedProvider.vue b/packages/vue-sdk/src/ReflagBootstrappedProvider.vue new file mode 100644 index 00000000..c110db8d --- /dev/null +++ b/packages/vue-sdk/src/ReflagBootstrappedProvider.vue @@ -0,0 +1,31 @@ + + + diff --git a/packages/vue-sdk/src/ReflagProvider.vue b/packages/vue-sdk/src/ReflagProvider.vue index e6aceb85..8f11e4bf 100644 --- a/packages/vue-sdk/src/ReflagProvider.vue +++ b/packages/vue-sdk/src/ReflagProvider.vue @@ -1,15 +1,9 @@ diff --git a/packages/vue-sdk/src/hooks.ts b/packages/vue-sdk/src/hooks.ts index ae38fd5b..a98699c9 100644 --- a/packages/vue-sdk/src/hooks.ts +++ b/packages/vue-sdk/src/hooks.ts @@ -11,22 +11,31 @@ export function useFlag(key: string): Flag { const client = useClient(); const ctx = injectSafe(); - const track = () => client?.value.track(key); + const track = () => client?.value?.track(key); const requestFeedback = (opts: RequestFlagFeedbackOptions) => - client.value.requestFeedback({ ...opts, flagKey: key }); + client.value?.requestFeedback({ ...opts, flagKey: key }); - const feature = ref(client.value.getFlag(key)); + const feature = ref( + client.value?.getFlag(key) || { + isEnabled: false, + config: { key: undefined, payload: undefined }, + }, + ); updateFlag(); function updateFlag() { - feature.value = client.value.getFlag(key); + if (client.value) { + feature.value = client.value.getFlag(key); + } } - client.value.on("flagsUpdated", updateFlag); - onBeforeUnmount(() => { - client.value.off("flagsUpdated", updateFlag); - }); + if (client.value) { + client.value.on("flagsUpdated", updateFlag); + onBeforeUnmount(() => { + client.value?.off("flagsUpdated", updateFlag); + }); + } return { key, @@ -61,7 +70,7 @@ export function useFlag(key: string): Flag { export function useTrack() { const client = useClient(); return (eventName: string, attributes?: Record | null) => - client?.value.track(eventName, attributes); + client?.value?.track(eventName, attributes); } /** @@ -90,7 +99,7 @@ export function useTrack() { export function useRequestFeedback() { const client = useClient(); return (options: RequestFeedbackData) => - client?.value.requestFeedback(options); + client?.value?.requestFeedback(options); } /** @@ -117,7 +126,7 @@ export function useRequestFeedback() { */ export function useSendFeedback() { const client = useClient(); - return (opts: UnassignedFeedback) => client?.value.feedback(opts); + return (opts: UnassignedFeedback) => client?.value?.feedback(opts); } /** @@ -143,7 +152,7 @@ export function useSendFeedback() { export function useUpdateUser() { const client = useClient(); return (opts: { [key: string]: string | number | undefined }) => - client?.value.updateUser(opts); + client?.value?.updateUser(opts); } /** @@ -169,7 +178,7 @@ export function useUpdateUser() { export function useUpdateCompany() { const client = useClient(); return (opts: { [key: string]: string | number | undefined }) => - client?.value.updateCompany(opts); + client?.value?.updateCompany(opts); } /** @@ -195,7 +204,7 @@ export function useUpdateCompany() { export function useUpdateOtherContext() { const client = useClient(); return (opts: { [key: string]: string | number | undefined }) => - client?.value.updateOtherContext(opts); + client?.value?.updateOtherContext(opts); } /** @@ -224,7 +233,7 @@ export function useIsLoading() { function injectSafe() { const ctx = inject(ProviderSymbol); - if (!ctx?.provider) { + if (!ctx) { throw new Error( `ReflagProvider is missing. Please ensure your component is wrapped with a ReflagProvider.`, ); diff --git a/packages/vue-sdk/src/index.ts b/packages/vue-sdk/src/index.ts index 64f43e8b..779d3e77 100644 --- a/packages/vue-sdk/src/index.ts +++ b/packages/vue-sdk/src/index.ts @@ -7,8 +7,15 @@ import { UserContext, } from "@reflag/browser-sdk"; +import ReflagBootstrappedProvider from "./ReflagBootstrappedProvider.vue"; import ReflagProvider from "./ReflagProvider.vue"; -import { EmptyFlagRemoteConfig, Flag, FlagType, ReflagProps } from "./types"; +import { + BootstrappedFlags, + EmptyFlagRemoteConfig, + Flag, + FlagType, + ReflagProps, +} from "./types"; export { useClient, @@ -21,11 +28,17 @@ export { useUpdateOtherContext, useUpdateUser, } from "./hooks"; -export type { ReflagProps, RequestFlagFeedbackOptions } from "./types"; +export type { + ReflagBaseProps, + ReflagBootstrappedProps, + ReflagProps, + RequestFlagFeedbackOptions, +} from "./types"; -export { ReflagProvider }; +export { ReflagBootstrappedProvider, ReflagProvider }; export type { + BootstrappedFlags, CheckEvent, CompanyContext, EmptyFlagRemoteConfig, @@ -38,5 +51,6 @@ export type { export default { install(app: App, _options?: ReflagProps) { app.component("ReflagProvider", ReflagProvider); + app.component("ReflagBootstrappedProvider", ReflagBootstrappedProvider); }, }; diff --git a/packages/vue-sdk/src/types.ts b/packages/vue-sdk/src/types.ts index 67ebf426..ebb76ba1 100644 --- a/packages/vue-sdk/src/types.ts +++ b/packages/vue-sdk/src/types.ts @@ -1,7 +1,9 @@ import type { Ref } from "vue"; import type { + FetchedFlags, InitOptions, + InitOptionsBootstrapped, ReflagClient, ReflagContext, RequestFeedbackData, @@ -47,20 +49,53 @@ export type TypedFlags = keyof Flags extends never export type FlagKey = keyof TypedFlags; export interface ProviderContextType { - client: Ref; + client: Ref; isLoading: Ref; - updatedCount: Ref; - provider: boolean; } -export type ReflagProps = ReflagContext & - InitOptions & { - debug?: boolean; - newReflagClient?: ( - ...args: ConstructorParameters - ) => ReflagClient; +export type BootstrappedFlags = { + context: ReflagContext; + flags: FetchedFlags; +}; + +/** + * Base props for the ReflagProvider and ReflagBootstrappedProvider. + * @internal + */ +export type ReflagBaseProps = { + debug?: boolean; + newReflagClient?: ( + ...args: ConstructorParameters + ) => ReflagClient; +}; + +/** + * Props for the ReflagProvider. + */ +export type ReflagProps = InitOptions & ReflagBaseProps; + +/** + * Props for the ReflagBootstrappedProvider. + */ +export type ReflagBootstrappedProps = Omit< + InitOptionsBootstrapped, + "bootstrappedFlags" | "user" | "company" | "otherContext" +> & + ReflagBaseProps & { + /** + * Pre-fetched flags to be used instead of fetching them from the server. + */ + flags?: BootstrappedFlags; }; +export type UseReflagProviderOptions = { + config: Omit & + ReflagBaseProps; + context?: ReflagContext; + bootstrappedFlags?: FetchedFlags; + isBootstrapped?: boolean; +}; + export type RequestFlagFeedbackOptions = Omit< RequestFeedbackData, "flagKey" | "featureId" diff --git a/packages/vue-sdk/src/useReflagProvider.ts b/packages/vue-sdk/src/useReflagProvider.ts new file mode 100644 index 00000000..50994eb4 --- /dev/null +++ b/packages/vue-sdk/src/useReflagProvider.ts @@ -0,0 +1,99 @@ +import canonicalJson from "canonical-json"; +import { computed, ref, shallowRef, watch } from "vue"; + +import { ReflagClient } from "@reflag/browser-sdk"; + +import { ProviderContextType, UseReflagProviderOptions } from "./types"; +import { SDK_VERSION } from "./version"; + +function removeUndefined(obj: Record) { + const t = obj; + for (const v in t) { + if (typeof t[v] == "object") removeUndefined(t[v]); + else if (t[v] == undefined) delete t[v]; + } + return t; +} + +/** + * Shared composable that handles the common logic for both ReflagProvider and ReflagBootstrappedProvider + */ +export function useReflagProvider({ + config, + context, + bootstrappedFlags, + isBootstrapped = false, +}: UseReflagProviderOptions): ProviderContextType { + const flagsLoading = ref(true); + + const { + newReflagClient = (...args) => new ReflagClient(...args), + debug, + ...initConfig + } = config; + + const clientRef = shallowRef(); + + function updateClient(): ReflagClient | undefined { + // For bootstrapped provider, don't initialize if flags are not provided + if (isBootstrapped && !bootstrappedFlags) { + flagsLoading.value = false; + return undefined; + } + + // Create base client options + const baseClientOptions = { + ...initConfig, + user: context?.user, + company: context?.company, + otherContext: context?.otherContext, + logger: debug ? console : undefined, + sdkVersion: SDK_VERSION, + }; + + // Add bootstrapped flags if this is a bootstrapped provider + const clientOptions = bootstrappedFlags + ? { ...baseClientOptions, bootstrappedFlags } + : baseClientOptions; + + const client = newReflagClient(clientOptions); + + flagsLoading.value = true; + client + .initialize() + .catch((e) => client.logger.error("failed to initialize client", e)) + .finally(() => { + flagsLoading.value = false; + }); + + return client; + } + + // Generate context key based to deduplicate initialization + const contextKey = computed(() => { + return canonicalJson( + removeUndefined({ + ...initConfig, + ...context, + flags: bootstrappedFlags ?? null, + }), + ); + }); + + watch( + contextKey, + () => { + // Stop the previous client if it exists + if (clientRef.value) { + void clientRef.value.stop(); + } + clientRef.value = updateClient(); + }, + { immediate: true }, + ); + + return { + isLoading: flagsLoading, + client: clientRef, + }; +} diff --git a/packages/vue-sdk/test/usage.test.ts b/packages/vue-sdk/test/usage.test.ts index db454585..a468cfec 100644 --- a/packages/vue-sdk/test/usage.test.ts +++ b/packages/vue-sdk/test/usage.test.ts @@ -1,20 +1,45 @@ import { mount } from "@vue/test-utils"; -import { describe, expect, test, vi } from "vitest"; +import { beforeAll, beforeEach, describe, expect, test, vi } from "vitest"; import { defineComponent, h, nextTick } from "vue"; -import { ReflagProvider, useClient } from "../src"; +import { ReflagClient } from "@reflag/browser-sdk"; -const fakeClient = { - initialize: vi.fn().mockResolvedValue(undefined), - stop: vi.fn(), - on: vi.fn(), -}; +import { + ReflagBootstrappedProvider, + ReflagProvider, + useClient, + useFlag, +} from "../src"; + +// Mock ReflagClient prototype methods like the React SDK tests +beforeAll(() => { + vi.spyOn(ReflagClient.prototype, "initialize").mockResolvedValue(undefined); + vi.spyOn(ReflagClient.prototype, "stop").mockResolvedValue(undefined); + vi.spyOn(ReflagClient.prototype, "getFlag").mockReturnValue({ + isEnabled: true, + config: { key: "default", payload: { message: "Hello" } }, + track: vi.fn().mockResolvedValue(undefined), + requestFeedback: vi.fn(), + setIsEnabledOverride: vi.fn(), + isEnabledOverride: null, + }); + vi.spyOn(ReflagClient.prototype, "getFlags").mockReturnValue({}); + vi.spyOn(ReflagClient.prototype, "on").mockReturnValue(() => { + // cleanup function + }); + vi.spyOn(ReflagClient.prototype, "off").mockImplementation(() => { + // off implementation + }); +}); + +beforeEach(() => { + vi.clearAllMocks(); +}); function getProvider() { return { props: { publishableKey: "key", - newReflagClient: () => fakeClient, }, }; } @@ -35,7 +60,7 @@ describe("ReflagProvider", () => { }); await nextTick(); - expect(wrapper.findComponent(Child).vm.client).toStrictEqual(fakeClient); + expect(wrapper.findComponent(Child).vm.client).toBeDefined(); }); test("throws without provider", () => { @@ -50,3 +75,63 @@ describe("ReflagProvider", () => { expect(() => mount(Comp)).toThrow(); }); }); + +describe("ReflagBootstrappedProvider", () => { + test("provides the client with bootstrapped flags", async () => { + const bootstrappedFlags = { + context: { + user: { id: "test-user" }, + company: { id: "test-company" }, + }, + flags: { + "test-flag": { + key: "test-flag", + isEnabled: true, + config: { key: "default", payload: { message: "Hello" } }, + }, + }, + }; + + const Child = defineComponent({ + setup() { + const client = useClient(); + const flag = useFlag("test-flag"); + return { client, flag }; + }, + template: "
    ", + }); + + const wrapper = mount(ReflagBootstrappedProvider, { + props: { + publishableKey: "key", + flags: bootstrappedFlags, + }, + slots: { default: () => h(Child) }, + }); + + await nextTick(); + expect(wrapper.findComponent(Child).vm.client).toBeDefined(); + expect(wrapper.findComponent(Child).vm.flag.isEnabled.value).toBe(true); + }); + + test("handles missing flags gracefully", async () => { + const Child = defineComponent({ + setup() { + const client = useClient(); + return { client }; + }, + template: "
    ", + }); + + const wrapper = mount(ReflagBootstrappedProvider, { + props: { + publishableKey: "key", + // No flags provided + }, + slots: { default: () => h(Child) }, + }); + + await nextTick(); + expect(wrapper.findComponent(Child).vm.client).toBeUndefined(); + }); +}); From d0fb7dac7d5890c00ddb6f4eddf027b07be88b20 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Tue, 16 Sep 2025 16:24:23 +0200 Subject: [PATCH 22/58] docs: add app router section --- packages/react-sdk/README.md | 107 ++++++++++++++++++++++++++++++++++- 1 file changed, 106 insertions(+), 1 deletion(-) diff --git a/packages/react-sdk/README.md b/packages/react-sdk/README.md index d9666cab..bd3afa6e 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -250,7 +250,7 @@ app.get("/bootstrap", (req, res) => { }); ``` -### Next.js SSR example +### Next.js Page Router SSR example For Next.js applications using server-side rendering, you can pre-fetch flags in `getServerSideProps`: @@ -311,6 +311,111 @@ function HuddleFeature() { This approach eliminates loading states and improves performance by avoiding the initial flags API call. +### Next.js App Router example + +For Next.js applications using the App Router (Next.js 13+), you can pre-fetch flags in Server Components and pass them to client components: + +```typescript +// app/layout.tsx (Server Component) +import { ReflagClient as ReflagNodeClient } from "@reflag/node-sdk"; +import { ClientProviders } from "./providers"; + +async function getBootstrapData() { + const serverClient = new ReflagNodeClient({ + secretKey: process.env.REFLAG_SECRET_KEY! + }); + await serverClient.initialize(); + + // In a real app, you'd get user/company from your auth system + const bootstrapData = serverClient.getFlagsForBootstrap({ + user: { id: "user123", name: "John Doe", email: "john@acme.com" }, + company: { id: "company456", name: "Acme Inc", plan: "enterprise" }, + other: { source: "web" } + }); + + return bootstrapData; +} + +export default async function RootLayout({ + children, +}: { + children: React.ReactNode; +}) { + const bootstrapData = await getBootstrapData(); + + return ( + + + + {children} + + + + ); +} +``` + +```typescript +// app/providers.tsx (Client Component) +"use client"; + +import { ReflagBootstrappedProvider, BootstrappedFlags } from "@reflag/react-sdk"; + +interface ClientProvidersProps { + children: React.ReactNode; + bootstrapData: BootstrappedFlags; +} + +export function ClientProviders({ children, bootstrapData }: ClientProvidersProps) { + return ( + + {children} + + ); +} +``` + +```typescript +// app/page.tsx (Server Component) +import { HuddleFeature } from "./huddle-feature"; + +export default function HomePage() { + return ( +
    +

    My App

    + +
    + ); +} +``` + +```typescript +// app/huddle-feature.tsx (Client Component) +"use client"; + +import { useFlag } from "@reflag/react-sdk"; + +export function HuddleFeature() { + const { isEnabled, track, config } = useFlag("huddle"); + + if (!isEnabled) return null; + + return ( +
    +

    Start a Huddle

    +

    Max participants: {config.payload?.maxParticipants ?? 10}

    +

    Video quality: {config.payload?.videoQuality ?? "standard"}

    + +
    + ); +} +``` + +This App Router approach leverages Server Components for server-side flag fetching while using Client Components only where React state and hooks are needed. + ## `` component The `` initializes the Reflag SDK, fetches flags and starts listening for automated feedback survey events. The component can be configured using a number of props: From e283941ea26691eb150f57883dbf109edac7b0cb Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Tue, 16 Sep 2025 16:46:43 +0200 Subject: [PATCH 23/58] feat: nextjs-boostrap-demo --- .../dev/nextjs-bootstrap-demo/.eslintrc.json | 3 + .../dev/nextjs-bootstrap-demo/.gitignore | 36 ++++++ .../dev/nextjs-bootstrap-demo/README.md | 11 ++ .../dev/nextjs-bootstrap-demo/app/favicon.ico | Bin 0 -> 25931 bytes .../dev/nextjs-bootstrap-demo/app/globals.css | 33 ++++++ .../dev/nextjs-bootstrap-demo/app/layout.tsx | 53 +++++++++ .../dev/nextjs-bootstrap-demo/app/page.tsx | 112 ++++++++++++++++++ .../components/Flags.tsx | 16 +++ .../components/Providers.tsx | 21 ++++ .../dev/nextjs-bootstrap-demo/next.config.mjs | 4 + .../dev/nextjs-bootstrap-demo/package.json | 28 +++++ .../nextjs-bootstrap-demo/postcss.config.mjs | 8 ++ .../dev/nextjs-bootstrap-demo/public/next.svg | 1 + .../nextjs-bootstrap-demo/public/vercel.svg | 1 + .../nextjs-bootstrap-demo/tailwind.config.ts | 20 ++++ .../dev/nextjs-bootstrap-demo/tsconfig.json | 26 ++++ yarn.lock | 22 +++- 17 files changed, 394 insertions(+), 1 deletion(-) create mode 100644 packages/react-sdk/dev/nextjs-bootstrap-demo/.eslintrc.json create mode 100644 packages/react-sdk/dev/nextjs-bootstrap-demo/.gitignore create mode 100644 packages/react-sdk/dev/nextjs-bootstrap-demo/README.md create mode 100644 packages/react-sdk/dev/nextjs-bootstrap-demo/app/favicon.ico create mode 100644 packages/react-sdk/dev/nextjs-bootstrap-demo/app/globals.css create mode 100644 packages/react-sdk/dev/nextjs-bootstrap-demo/app/layout.tsx create mode 100644 packages/react-sdk/dev/nextjs-bootstrap-demo/app/page.tsx create mode 100644 packages/react-sdk/dev/nextjs-bootstrap-demo/components/Flags.tsx create mode 100644 packages/react-sdk/dev/nextjs-bootstrap-demo/components/Providers.tsx create mode 100644 packages/react-sdk/dev/nextjs-bootstrap-demo/next.config.mjs create mode 100644 packages/react-sdk/dev/nextjs-bootstrap-demo/package.json create mode 100644 packages/react-sdk/dev/nextjs-bootstrap-demo/postcss.config.mjs create mode 100644 packages/react-sdk/dev/nextjs-bootstrap-demo/public/next.svg create mode 100644 packages/react-sdk/dev/nextjs-bootstrap-demo/public/vercel.svg create mode 100644 packages/react-sdk/dev/nextjs-bootstrap-demo/tailwind.config.ts create mode 100644 packages/react-sdk/dev/nextjs-bootstrap-demo/tsconfig.json diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/.eslintrc.json b/packages/react-sdk/dev/nextjs-bootstrap-demo/.eslintrc.json new file mode 100644 index 00000000..bffb357a --- /dev/null +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/.eslintrc.json @@ -0,0 +1,3 @@ +{ + "extends": "next/core-web-vitals" +} diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/.gitignore b/packages/react-sdk/dev/nextjs-bootstrap-demo/.gitignore new file mode 100644 index 00000000..fd3dbb57 --- /dev/null +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/.gitignore @@ -0,0 +1,36 @@ +# See https://help.github.com/articles/ignoring-files/ for more about ignoring files. + +# dependencies +/node_modules +/.pnp +.pnp.js +.yarn/install-state.gz + +# testing +/coverage + +# next.js +/.next/ +/out/ + +# production +/build + +# misc +.DS_Store +*.pem + +# debug +npm-debug.log* +yarn-debug.log* +yarn-error.log* + +# local env files +.env*.local + +# vercel +.vercel + +# typescript +*.tsbuildinfo +next-env.d.ts diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/README.md b/packages/react-sdk/dev/nextjs-bootstrap-demo/README.md new file mode 100644 index 00000000..bdf6c212 --- /dev/null +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/README.md @@ -0,0 +1,11 @@ +This is a [Next.js](https://nextjs.org/) project bootstrapped with [`create-next-app`](https://github.com/vercel/next.js/tree/canary/packages/create-next-app). + +The purpose of this project is to demonstrate usage integration with the Reflag React SDK. + +## Getting Started + +Run the development server: + +```bash +yarn dev +``` diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/app/favicon.ico b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/favicon.ico new file mode 100644 index 0000000000000000000000000000000000000000..718d6fea4835ec2d246af9800eddb7ffb276240c GIT binary patch literal 25931 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 LrJugUA?W`A8`#=m literal 0 HcmV?d00001 diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/app/globals.css b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/globals.css new file mode 100644 index 00000000..875c01e8 --- /dev/null +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/globals.css @@ -0,0 +1,33 @@ +@tailwind base; +@tailwind components; +@tailwind utilities; + +:root { + --foreground-rgb: 0, 0, 0; + --background-start-rgb: 214, 219, 220; + --background-end-rgb: 255, 255, 255; +} + +@media (prefers-color-scheme: dark) { + :root { + --foreground-rgb: 255, 255, 255; + --background-start-rgb: 0, 0, 0; + --background-end-rgb: 0, 0, 0; + } +} + +body { + color: rgb(var(--foreground-rgb)); + background: linear-gradient( + to bottom, + transparent, + rgb(var(--background-end-rgb)) + ) + rgb(var(--background-start-rgb)); +} + +@layer utilities { + .text-balance { + text-wrap: balance; + } +} diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/app/layout.tsx b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/layout.tsx new file mode 100644 index 00000000..fbb7d50f --- /dev/null +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/layout.tsx @@ -0,0 +1,53 @@ +import type { Metadata } from "next"; +import { Inter } from "next/font/google"; +import "./globals.css"; +import { Providers } from "@/components/Providers"; +import { ReflagClient as ReflagNodeClient } from "@reflag/node-sdk"; + +const inter = Inter({ subsets: ["latin"] }); + +export const metadata: Metadata = { + title: "Create Next App", + description: "Generated by create next app", +}; + +const publishableKey = process.env.REFLAG_PUBLISHABLE_KEY || ""; +const secretKey = process.env.REFLAG_SECRET_KEY || ""; + +async function getBootstrappedFlags() { + const serverClient = new ReflagNodeClient({ + secretKey, + }); + await serverClient.initialize(); + + // In a real app, you'd get user/company from your auth system + const flags = serverClient.getFlagsForBootstrap({ + user: { + id: "demo-user", + email: "demo-user@example.com", + "optin-huddles": true, + }, + company: { id: "demo-company", name: "Demo Company" }, + other: { source: "web" }, + }); + + return flags; +} + +export default async function RootLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + const flags = await getBootstrappedFlags(); + + return ( + + + + {children} + + + + ); +} diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/app/page.tsx b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/page.tsx new file mode 100644 index 00000000..cce41017 --- /dev/null +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/page.tsx @@ -0,0 +1,112 @@ +import Image from "next/image"; +import { Flags } from "@/components/Flags"; + +export default function Home() { + return ( +
    + + +
    + Next.js Logo +
    + + + + +
    + ); +} diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/components/Flags.tsx b/packages/react-sdk/dev/nextjs-bootstrap-demo/components/Flags.tsx new file mode 100644 index 00000000..f9d04efc --- /dev/null +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/components/Flags.tsx @@ -0,0 +1,16 @@ +"use client"; + +import React from "react"; +import { useFlag } from "@reflag/react-sdk"; + +export const Flags = () => { + const { isEnabled } = useFlag("huddles"); + return ( +
    +

    Huddles feature enabled:

    +
    +        {JSON.stringify(isEnabled)}
    +      
    +
    + ); +}; diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/components/Providers.tsx b/packages/react-sdk/dev/nextjs-bootstrap-demo/components/Providers.tsx new file mode 100644 index 00000000..70cb50e0 --- /dev/null +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/components/Providers.tsx @@ -0,0 +1,21 @@ +"use client"; + +import React, { ReactNode } from "react"; +import { + BootstrappedFlags, + ReflagBootstrappedProvider, +} from "@reflag/react-sdk"; + +type Props = { + publishableKey: string; + flags: BootstrappedFlags; + children: ReactNode; +}; + +export const Providers = ({ publishableKey, flags, children }: Props) => { + return ( + + {children} + + ); +}; diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/next.config.mjs b/packages/react-sdk/dev/nextjs-bootstrap-demo/next.config.mjs new file mode 100644 index 00000000..4678774e --- /dev/null +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/next.config.mjs @@ -0,0 +1,4 @@ +/** @type {import('next').NextConfig} */ +const nextConfig = {}; + +export default nextConfig; diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/package.json b/packages/react-sdk/dev/nextjs-bootstrap-demo/package.json new file mode 100644 index 00000000..2d071fb0 --- /dev/null +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/package.json @@ -0,0 +1,28 @@ +{ + "name": "nextjs-bootstrap-demo", + "version": "0.2.0", + "private": true, + "scripts": { + "dev": "next dev", + "build": "next build", + "start": "next start", + "lint": "next lint" + }, + "dependencies": { + "@reflag/node-sdk": "workspace:^", + "@reflag/react-sdk": "workspace:^", + "next": "14.2.21", + "react": "^18", + "react-dom": "^18" + }, + "devDependencies": { + "@types/node": "^22.12.0", + "@types/react": "^18", + "@types/react-dom": "^18", + "eslint": "^8", + "eslint-config-next": "14.2.5", + "postcss": "^8", + "tailwindcss": "^3.4.1", + "typescript": "^5.7.3" + } +} diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/postcss.config.mjs b/packages/react-sdk/dev/nextjs-bootstrap-demo/postcss.config.mjs new file mode 100644 index 00000000..1a69fd2a --- /dev/null +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/postcss.config.mjs @@ -0,0 +1,8 @@ +/** @type {import('postcss-load-config').Config} */ +const config = { + plugins: { + tailwindcss: {}, + }, +}; + +export default config; diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/public/next.svg b/packages/react-sdk/dev/nextjs-bootstrap-demo/public/next.svg new file mode 100644 index 00000000..5174b28c --- /dev/null +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/public/next.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/public/vercel.svg b/packages/react-sdk/dev/nextjs-bootstrap-demo/public/vercel.svg new file mode 100644 index 00000000..d2f84222 --- /dev/null +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/public/vercel.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/tailwind.config.ts b/packages/react-sdk/dev/nextjs-bootstrap-demo/tailwind.config.ts new file mode 100644 index 00000000..7e4bd91a --- /dev/null +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/tailwind.config.ts @@ -0,0 +1,20 @@ +import type { Config } from "tailwindcss"; + +const config: Config = { + content: [ + "./pages/**/*.{js,ts,jsx,tsx,mdx}", + "./components/**/*.{js,ts,jsx,tsx,mdx}", + "./app/**/*.{js,ts,jsx,tsx,mdx}", + ], + theme: { + extend: { + backgroundImage: { + "gradient-radial": "radial-gradient(var(--tw-gradient-stops))", + "gradient-conic": + "conic-gradient(from 180deg at 50% 50%, var(--tw-gradient-stops))", + }, + }, + }, + plugins: [], +}; +export default config; diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/tsconfig.json b/packages/react-sdk/dev/nextjs-bootstrap-demo/tsconfig.json new file mode 100644 index 00000000..e7ff90fd --- /dev/null +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/tsconfig.json @@ -0,0 +1,26 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "skipLibCheck": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "plugins": [ + { + "name": "next" + } + ], + "paths": { + "@/*": ["./*"] + } + }, + "include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/yarn.lock b/yarn.lock index 8ded1dbf..bb9ed4d8 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3055,7 +3055,7 @@ __metadata: languageName: unknown linkType: soft -"@reflag/node-sdk@npm:1.2.0, @reflag/node-sdk@workspace:packages/node-sdk": +"@reflag/node-sdk@npm:1.2.0, @reflag/node-sdk@workspace:^, @reflag/node-sdk@workspace:packages/node-sdk": version: 0.0.0-use.local resolution: "@reflag/node-sdk@workspace:packages/node-sdk" dependencies: @@ -13277,6 +13277,26 @@ __metadata: languageName: node linkType: hard +"nextjs-bootstrap-demo@workspace:packages/react-sdk/dev/nextjs-bootstrap-demo": + version: 0.0.0-use.local + resolution: "nextjs-bootstrap-demo@workspace:packages/react-sdk/dev/nextjs-bootstrap-demo" + dependencies: + "@reflag/node-sdk": "workspace:^" + "@reflag/react-sdk": "workspace:^" + "@types/node": "npm:^22.12.0" + "@types/react": "npm:^18" + "@types/react-dom": "npm:^18" + eslint: "npm:^8" + eslint-config-next: "npm:14.2.5" + next: "npm:14.2.21" + postcss: "npm:^8" + react: "npm:^18" + react-dom: "npm:^18" + tailwindcss: "npm:^3.4.1" + typescript: "npm:^5.7.3" + languageName: unknown + linkType: soft + "nextjs-flag-demo@workspace:packages/react-sdk/dev/nextjs-flag-demo": version: 0.0.0-use.local resolution: "nextjs-flag-demo@workspace:packages/react-sdk/dev/nextjs-flag-demo" From f5ac61ae60a28ff92dd9afa03100860d1e53253e Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Tue, 16 Sep 2025 17:01:38 +0200 Subject: [PATCH 24/58] fix: offline mode when run in CI --- packages/react-sdk/dev/nextjs-bootstrap-demo/app/layout.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/app/layout.tsx b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/layout.tsx index fbb7d50f..9c604c72 100644 --- a/packages/react-sdk/dev/nextjs-bootstrap-demo/app/layout.tsx +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/layout.tsx @@ -13,10 +13,12 @@ export const metadata: Metadata = { const publishableKey = process.env.REFLAG_PUBLISHABLE_KEY || ""; const secretKey = process.env.REFLAG_SECRET_KEY || ""; +const offline = process.env.CI === "true"; async function getBootstrappedFlags() { const serverClient = new ReflagNodeClient({ secretKey, + offline, }); await serverClient.initialize(); From 71aa439a704f389ce2a1be54d30652d72f5b2ff8 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Wed, 17 Sep 2025 10:05:01 +0200 Subject: [PATCH 25/58] fix: bug, warning, versions, logs --- packages/browser-sdk/src/client.ts | 2 +- packages/browser-sdk/src/feedback/feedback.ts | 2 +- packages/browser-sdk/src/flag/flags.ts | 3 ++- packages/browser-sdk/test/flags.test.ts | 2 +- packages/node-sdk/package.json | 2 +- packages/openfeature-node-provider/package.json | 2 +- packages/react-sdk/package.json | 2 +- packages/react-sdk/src/index.tsx | 2 +- packages/vue-sdk/package.json | 2 +- packages/vue-sdk/src/useReflagProvider.ts | 2 +- yarn.lock | 4 ++-- 11 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 6328661d..e8f8677e 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -507,7 +507,7 @@ export class ReflagClient { */ async initialize() { if (this.config.initialized) { - this.logger.info("Reflag client already initialized"); + this.logger.warn("Reflag client already initialized"); return; } diff --git a/packages/browser-sdk/src/feedback/feedback.ts b/packages/browser-sdk/src/feedback/feedback.ts index b141954b..f4d0b993 100644 --- a/packages/browser-sdk/src/feedback/feedback.ts +++ b/packages/browser-sdk/src/feedback/feedback.ts @@ -284,7 +284,7 @@ export class AutoFeedback { */ async initialize() { if (this.initialized) { - this.logger.error("auto. feedback client already initialized"); + this.logger.warn("auto. feedback client already initialized"); return; } this.initialized = true; diff --git a/packages/browser-sdk/src/flag/flags.ts b/packages/browser-sdk/src/flag/flags.ts index 071bdf9b..7e49a8df 100644 --- a/packages/browser-sdk/src/flag/flags.ts +++ b/packages/browser-sdk/src/flag/flags.ts @@ -282,13 +282,14 @@ export class FlagsClient { if (options?.bootstrappedFlags) { this.initialized = true; this.fetchedFlags = options.bootstrappedFlags; + this.warnMissingFlagContextFields(this.fetchedFlags); this.flags = this.mergeFlags(this.fetchedFlags, this.flagOverrides); } } async initialize() { if (this.initialized) { - this.logger.error("flags client already initialized"); + this.logger.warn("flags client already initialized"); return; } this.setFetchedFlags((await this.maybeFetchFlags()) || {}); diff --git a/packages/browser-sdk/test/flags.test.ts b/packages/browser-sdk/test/flags.test.ts index e2e165f9..452cd682 100644 --- a/packages/browser-sdk/test/flags.test.ts +++ b/packages/browser-sdk/test/flags.test.ts @@ -108,7 +108,7 @@ describe("FlagsClient", () => { expect(testLogger.warn).toHaveBeenCalledTimes(1); vi.advanceTimersByTime(60 * 1000); await flagsClient.initialize(); - expect(testLogger.error).toHaveBeenCalledTimes(1); + expect(testLogger.warn).toHaveBeenCalledTimes(2); }); test("ignores undefined context", async () => { diff --git a/packages/node-sdk/package.json b/packages/node-sdk/package.json index 9add3470..1147dcd2 100644 --- a/packages/node-sdk/package.json +++ b/packages/node-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@reflag/node-sdk", - "version": "1.2.0", + "version": "1.1.0", "license": "MIT", "repository": { "type": "git", diff --git a/packages/openfeature-node-provider/package.json b/packages/openfeature-node-provider/package.json index 8ba82b9c..27723847 100644 --- a/packages/openfeature-node-provider/package.json +++ b/packages/openfeature-node-provider/package.json @@ -50,7 +50,7 @@ "vitest": "~1.6.0" }, "dependencies": { - "@reflag/node-sdk": "1.2.0" + "@reflag/node-sdk": "1.1.0" }, "peerDependencies": { "@openfeature/server-sdk": ">=1.16.1" diff --git a/packages/react-sdk/package.json b/packages/react-sdk/package.json index df72301c..20edbd0a 100644 --- a/packages/react-sdk/package.json +++ b/packages/react-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@reflag/react-sdk", - "version": "2.0.0", + "version": "1.2.0", "license": "MIT", "repository": { "type": "git", diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index 9b371d57..107a7d33 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -124,7 +124,7 @@ export type FlagKey = keyof TypedFlags; const SDK_VERSION = `react-sdk/${version}`; function removeUndefined(obj: Record) { - const t = obj; + const t = { ...obj }; for (const v in t) { if (typeof t[v] == "object") removeUndefined(t[v]); else if (t[v] == undefined) delete t[v]; diff --git a/packages/vue-sdk/package.json b/packages/vue-sdk/package.json index c6160754..117fb7f5 100644 --- a/packages/vue-sdk/package.json +++ b/packages/vue-sdk/package.json @@ -1,6 +1,6 @@ { "name": "@reflag/vue-sdk", - "version": "1.1.0", + "version": "1.2.0", "license": "MIT", "repository": { "type": "git", diff --git a/packages/vue-sdk/src/useReflagProvider.ts b/packages/vue-sdk/src/useReflagProvider.ts index 50994eb4..d954369c 100644 --- a/packages/vue-sdk/src/useReflagProvider.ts +++ b/packages/vue-sdk/src/useReflagProvider.ts @@ -7,7 +7,7 @@ import { ProviderContextType, UseReflagProviderOptions } from "./types"; import { SDK_VERSION } from "./version"; function removeUndefined(obj: Record) { - const t = obj; + const t = { ...obj }; for (const v in t) { if (typeof t[v] == "object") removeUndefined(t[v]); else if (t[v] == undefined) delete t[v]; diff --git a/yarn.lock b/yarn.lock index bb9ed4d8..142d9ffd 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3055,7 +3055,7 @@ __metadata: languageName: unknown linkType: soft -"@reflag/node-sdk@npm:1.2.0, @reflag/node-sdk@workspace:^, @reflag/node-sdk@workspace:packages/node-sdk": +"@reflag/node-sdk@npm:1.1.0, @reflag/node-sdk@workspace:^, @reflag/node-sdk@workspace:packages/node-sdk": version: 0.0.0-use.local resolution: "@reflag/node-sdk@workspace:packages/node-sdk" dependencies: @@ -3107,7 +3107,7 @@ __metadata: "@openfeature/core": "npm:^1.5.0" "@openfeature/server-sdk": "npm:>=1.16.1" "@reflag/eslint-config": "npm:~0.0.2" - "@reflag/node-sdk": "npm:1.2.0" + "@reflag/node-sdk": "npm:1.1.0" "@reflag/tsconfig": "npm:~0.0.2" "@types/node": "npm:^22.12.0" eslint: "npm:^9.21.0" From 38d5a4dbd0b864caeba1c023bfb1cd7c46bfea11 Mon Sep 17 00:00:00 2001 From: Erik Hughes Date: Wed, 17 Sep 2025 12:07:52 +0200 Subject: [PATCH 26/58] fix: update docs and examples --- packages/browser-sdk/README.md | 7 ----- packages/browser-sdk/index.html | 29 ++++++++++++------ packages/node-sdk/README.md | 7 ----- packages/react-sdk/README.md | 9 +----- .../dev/nextjs-flag-demo/components/Flags.tsx | 4 +-- .../nextjs-flag-demo/components/Providers.tsx | 8 +++-- packages/react-sdk/dev/plain/app.tsx | 30 +++++++++++-------- packages/vue-sdk/README.md | 9 +----- 8 files changed, 48 insertions(+), 55 deletions(-) diff --git a/packages/browser-sdk/README.md b/packages/browser-sdk/README.md index 5cdcb4ec..fbb321ae 100644 --- a/packages/browser-sdk/README.md +++ b/packages/browser-sdk/README.md @@ -231,13 +231,6 @@ generate a `check` event, contrary to the `config` property on the object return For server-side rendered applications, you can eliminate the initial network request by bootstrapping the client with pre-fetched flag data. -### Key benefits - -- **Faster initial rendering**: No need to wait for flag fetch requests -- **Better SEO**: Flags are available immediately during SSR -- **Reduced server load**: Flags can be cached and reused across requests -- **Offline capability**: Works without an internet connection when flags are pre-fetched - ### Init options bootstrapped ```typescript diff --git a/packages/browser-sdk/index.html b/packages/browser-sdk/index.html index 4cb973bc..3d089cd0 100644 --- a/packages/browser-sdk/index.html +++ b/packages/browser-sdk/index.html @@ -15,6 +15,7 @@ const urlParams = new URLSearchParams(window.location.search); const publishableKey = urlParams.get("publishableKey"); const flagKey = urlParams.get("flagKey") ?? "huddles"; + const isBootstrapped = urlParams.get("bootstrapped") === "true";