From d024d2f1e162a90322b86f410a73b5f3307ff26a Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 11 Mar 2026 14:06:37 +0100 Subject: [PATCH 1/6] Ensure isLoading stays true while flags refetch for context changes Limit loading transitions to context-driven refetches; keep refresh() promise-based without toggling global loading state. --- packages/browser-sdk/src/client.ts | 41 ++++++++++++++++-- packages/browser-sdk/test/client.test.ts | 29 +++++++++++++ packages/react-sdk/src/index.tsx | 3 +- packages/react-sdk/test/usage.test.tsx | 53 +++++++++++++++++++++++- 4 files changed, 120 insertions(+), 6 deletions(-) diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index 84cd66675..eaf5cce15 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -386,6 +386,7 @@ function shouldShowToolbar(opts: InitOptions) { */ export class ReflagClient { private state: State = "idle"; + private refetchingFlagsCount = 0; private readonly publishableKey: string; private context: ReflagContext; private config: Config; @@ -623,7 +624,9 @@ export class ReflagClient { void this.updateAutoFeedbackUser(String(user.id)); } - await this.flagsClient.setContext(this.context); + await this.withFlagsRefetchLoading(() => + this.flagsClient.setContext(this.context), + ); } /** @@ -645,7 +648,9 @@ export class ReflagClient { this.context.company = newCompanyContext; void this.company(); - await this.flagsClient.setContext(this.context); + await this.withFlagsRefetchLoading(() => + this.flagsClient.setContext(this.context), + ); } /** @@ -667,7 +672,9 @@ export class ReflagClient { if (deepEqual(this.context.other, newOtherContext)) return; this.context.other = newOtherContext; - await this.flagsClient.setContext(this.context); + await this.withFlagsRefetchLoading(() => + this.flagsClient.setContext(this.context), + ); } /** @@ -712,7 +719,9 @@ export class ReflagClient { } } - await this.flagsClient.setContext(this.context); + await this.withFlagsRefetchLoading(() => + this.flagsClient.setContext(this.context), + ); } /** @@ -981,6 +990,30 @@ export class ReflagClient { this.hooks.trigger("stateUpdated", state); } + private async withFlagsRefetchLoading(cb: () => Promise) { + const shouldUpdateLoadingState = this.state === "initialized"; + + if (shouldUpdateLoadingState) { + this.refetchingFlagsCount += 1; + if (this.refetchingFlagsCount === 1) { + this.setState("initializing"); + } + } + + try { + return await cb(); + } finally { + if (!shouldUpdateLoadingState) { + return; + } + + this.refetchingFlagsCount = Math.max(this.refetchingFlagsCount - 1, 0); + if (this.refetchingFlagsCount === 0 && this.state !== "stopped") { + this.setState("initialized"); + } + } + } + private sendCheckEvent(checkEvent: CheckEvent) { return this.flagsClient.sendCheckEvent(checkEvent, () => { this.hooks.trigger("check", checkEvent); diff --git a/packages/browser-sdk/test/client.test.ts b/packages/browser-sdk/test/client.test.ts index bf1fe2281..b362ec1f5 100644 --- a/packages/browser-sdk/test/client.test.ts +++ b/packages/browser-sdk/test/client.test.ts @@ -152,6 +152,35 @@ describe("ReflagClient", () => { expect(checkHook).not.toHaveBeenCalled(); expect(flagsUpdated).not.toHaveBeenCalled(); }); + + it("sets state to initializing while refetching flags after initialization", async () => { + await client.initialize(); + + let resolveFetch: (() => void) | undefined; + const setContextPromise = new Promise((resolve) => { + resolveFetch = resolve; + }); + const setContext = vi + .spyOn(FlagsClient.prototype, "setContext") + .mockImplementation(async () => { + await setContextPromise; + }); + + const stateUpdated = vi.fn(); + client.on("stateUpdated", stateUpdated); + + const updatePromise = client.updateOtherContext({ workspaceId: "ws-1" }); + + expect(client.getState()).toBe("initializing"); + expect(stateUpdated).toHaveBeenCalledWith("initializing"); + expect(setContext).toHaveBeenCalledWith(client["context"]); + + resolveFetch?.(); + await updatePromise; + + expect(client.getState()).toBe("initialized"); + expect(stateUpdated).toHaveBeenLastCalledWith("initialized"); + }); }); describe("offline mode", () => { diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index 26a69b71f..13bed37ea 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -5,6 +5,7 @@ import React, { ReactNode, useContext, useEffect, + useLayoutEffect, useMemo, useState, } from "react"; @@ -667,7 +668,7 @@ export function useOnEvent( `ReflagProvider is missing and no client was provided. Please ensure your component is wrapped with a ReflagProvider.`, ); } - useEffect(() => { + useLayoutEffect(() => { return resolvedClient.on(event, handler); }, [resolvedClient, event, handler]); } diff --git a/packages/react-sdk/test/usage.test.tsx b/packages/react-sdk/test/usage.test.tsx index 8b5d5301e..4a0627fcd 100644 --- a/packages/react-sdk/test/usage.test.tsx +++ b/packages/react-sdk/test/usage.test.tsx @@ -1,5 +1,5 @@ import React from "react"; -import { render, renderHook, waitFor } from "@testing-library/react"; +import { act, render, renderHook, waitFor } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; import { @@ -1065,6 +1065,57 @@ describe("useIsLoading", () => { unmount(); }); + test("returns loading state while refetching flags after a context change", async () => { + function LoadingState() { + return {String(useIsLoading())}; + } + + const client = new ReflagClient({ + publishableKey: "test-key-loading-context-change", + user, + company, + other, + bootstrappedFlags: { + abc: { + key: "abc", + isEnabled: true, + targetingVersion: 1, + }, + }, + }); + await client.initialize(); + + const { getByTestId, unmount } = render( + + + , + ); + + await waitFor(() => { + expect(getByTestId("loading-state").textContent).toBe("false"); + }); + + await act(async () => {}); + + act(() => { + (client as any).hooks.trigger("stateUpdated", "initializing"); + }); + + await waitFor(() => { + expect(getByTestId("loading-state").textContent).toBe("true"); + }); + + act(() => { + (client as any).hooks.trigger("stateUpdated", "initialized"); + }); + + await waitFor(() => { + expect(getByTestId("loading-state").textContent).toBe("false"); + }); + + unmount(); + }); + test("throws error when used outside provider", () => { const consoleErrorSpy = vi .spyOn(console, "error") From 960019ed34f888ec701461158e9ed225cb042c35 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 11 Mar 2026 15:45:33 +0100 Subject: [PATCH 2/6] Fix SSR event hook and partial context warnings --- packages/browser-sdk/src/client.ts | 117 +++++++++++------------ packages/browser-sdk/test/client.test.ts | 86 ++++++++++++++++- packages/react-sdk/src/index.tsx | 5 +- packages/react-sdk/test/usage.test.tsx | 30 ++++++ 4 files changed, 171 insertions(+), 67 deletions(-) diff --git a/packages/browser-sdk/src/client.ts b/packages/browser-sdk/src/client.ts index eaf5cce15..877a82768 100644 --- a/packages/browser-sdk/src/client.ts +++ b/packages/browser-sdk/src/client.ts @@ -386,7 +386,6 @@ function shouldShowToolbar(opts: InitOptions) { */ export class ReflagClient { private state: State = "idle"; - private refetchingFlagsCount = 0; private readonly publishableKey: string; private context: ReflagContext; private config: Config; @@ -607,25 +606,19 @@ export class ReflagClient { * @param user */ async updateUser(user: { [key: string]: string | number | undefined }) { - const userIdChanged = user.id && user.id !== this.context.user?.id; const newUserContext = { ...this.context.user, ...user, id: user.id ?? this.context.user?.id, }; - // Nothing has changed, skipping update - if (deepEqual(this.context.user, newUserContext)) return; - this.context.user = newUserContext; - void this.user(); - - // Update the feedback user if the user ID has changed - if (userIdChanged) { - void this.updateAutoFeedbackUser(String(user.id)); - } - - await this.withFlagsRefetchLoading(() => - this.flagsClient.setContext(this.context), + return this.applyContext( + { + user: newUserContext, + company: this.context.company, + other: this.context.other, + }, + { warnOnMissingIds: false }, ); } @@ -643,13 +636,13 @@ export class ReflagClient { id: company.id ?? this.context.company?.id, }; - // Nothing has changed, skipping update - if (deepEqual(this.context.company, newCompanyContext)) return; - this.context.company = newCompanyContext; - void this.company(); - - await this.withFlagsRefetchLoading(() => - this.flagsClient.setContext(this.context), + return this.applyContext( + { + user: this.context.user, + company: newCompanyContext, + other: this.context.other, + }, + { warnOnMissingIds: false }, ); } @@ -668,12 +661,13 @@ export class ReflagClient { ...otherContext, }; - // Nothing has changed, skipping update - if (deepEqual(this.context.other, newOtherContext)) return; - this.context.other = newOtherContext; - - await this.withFlagsRefetchLoading(() => - this.flagsClient.setContext(this.context), + return this.applyContext( + { + user: this.context.user, + company: this.context.company, + other: newOtherContext, + }, + { warnOnMissingIds: false }, ); } @@ -683,9 +677,15 @@ export class ReflagClient { * * @param context The context to update. */ - async setContext({ otherContext, ...context }: ReflagDeprecatedContext) { - const userIdChanged = - context.user?.id && context.user.id !== this.context.user?.id; + async setContext(context: ReflagDeprecatedContext) { + return this.applyContext(context, { warnOnMissingIds: true }); + } + + private async applyContext( + { otherContext, ...context }: ReflagDeprecatedContext, + { warnOnMissingIds }: { warnOnMissingIds: boolean }, + ) { + const previousContext = this.context; // Create a new context object making sure to clone the user and company objects const newContext = { @@ -694,34 +694,49 @@ export class ReflagClient { other: { ...otherContext, ...context.other }, }; - if (!context.user?.id) { + if (warnOnMissingIds && !context.user?.id) { this.logger.warn("No user Id provided in context, user will be ignored"); } - if (!context.company?.id) { + if (warnOnMissingIds && !context.company?.id) { this.logger.warn( "No company Id provided in context, company will be ignored", ); } // Nothing has changed, skipping update - if (deepEqual(this.context, newContext)) return; + if (deepEqual(previousContext, newContext)) return; + + const userChanged = !deepEqual(previousContext.user, newContext.user); + const companyChanged = !deepEqual( + previousContext.company, + newContext.company, + ); + const userIdChanged = + !!newContext.user?.id && newContext.user.id !== previousContext.user?.id; + this.context = newContext; - if (context.company) { + if (companyChanged) { void this.company(); } - if (context.user) { + if (userChanged) { void this.user(); // Update the automatic feedback user if the user ID has changed if (userIdChanged) { - void this.updateAutoFeedbackUser(String(context.user.id)); + void this.updateAutoFeedbackUser(String(newContext.user!.id)); } } - await this.withFlagsRefetchLoading(() => - this.flagsClient.setContext(this.context), - ); + const shouldTrackLoading = this.state === "initialized"; + if (shouldTrackLoading) { + this.setState("initializing"); + } + + const didApply = await this.flagsClient.setContext(this.context); + if (didApply && this.state === "initializing") { + this.setState("initialized"); + } } /** @@ -990,30 +1005,6 @@ export class ReflagClient { this.hooks.trigger("stateUpdated", state); } - private async withFlagsRefetchLoading(cb: () => Promise) { - const shouldUpdateLoadingState = this.state === "initialized"; - - if (shouldUpdateLoadingState) { - this.refetchingFlagsCount += 1; - if (this.refetchingFlagsCount === 1) { - this.setState("initializing"); - } - } - - try { - return await cb(); - } finally { - if (!shouldUpdateLoadingState) { - return; - } - - this.refetchingFlagsCount = Math.max(this.refetchingFlagsCount - 1, 0); - if (this.refetchingFlagsCount === 0 && this.state !== "stopped") { - this.setState("initialized"); - } - } - } - private sendCheckEvent(checkEvent: CheckEvent) { return this.flagsClient.sendCheckEvent(checkEvent, () => { this.hooks.trigger("check", checkEvent); diff --git a/packages/browser-sdk/test/client.test.ts b/packages/browser-sdk/test/client.test.ts index b362ec1f5..17069b20e 100644 --- a/packages/browser-sdk/test/client.test.ts +++ b/packages/browser-sdk/test/client.test.ts @@ -44,6 +44,20 @@ describe("ReflagClient", () => { }); expect(flagClientSetContext).toHaveBeenCalledWith(client["context"]); }); + + it("does not warn about a missing company when updating a user-only context", async () => { + client = new ReflagClient({ + publishableKey: "test-key-user-only", + user: { id: "user1" }, + }); + const warnSpy = vi.spyOn(client.logger, "warn"); + + await client.updateUser({ name: "Updated User" }); + + expect(warnSpy).not.toHaveBeenCalledWith( + "No company Id provided in context, company will be ignored", + ); + }); }); describe("updateCompany", () => { @@ -157,13 +171,13 @@ describe("ReflagClient", () => { await client.initialize(); let resolveFetch: (() => void) | undefined; - const setContextPromise = new Promise((resolve) => { - resolveFetch = resolve; + const setContextPromise = new Promise((resolve) => { + resolveFetch = () => resolve(true); }); const setContext = vi .spyOn(FlagsClient.prototype, "setContext") .mockImplementation(async () => { - await setContextPromise; + return setContextPromise; }); const stateUpdated = vi.fn(); @@ -181,6 +195,72 @@ describe("ReflagClient", () => { expect(client.getState()).toBe("initialized"); expect(stateUpdated).toHaveBeenLastCalledWith("initialized"); }); + + it("keeps loading tied to the latest context update", async () => { + await client.initialize(); + + let resolveFirstFetch: (() => void) | undefined; + let resolveSecondFetch: (() => void) | undefined; + const firstFetch = new Promise((resolve) => { + resolveFirstFetch = () => resolve(false); + }); + const secondFetch = new Promise((resolve) => { + resolveSecondFetch = () => resolve(true); + }); + + vi.spyOn(FlagsClient.prototype, "setContext") + .mockImplementationOnce(async () => firstFetch) + .mockImplementationOnce(async () => secondFetch); + + const firstUpdate = client.updateOtherContext({ workspaceId: "ws-1" }); + const secondUpdate = client.updateOtherContext({ workspaceId: "ws-2" }); + + expect(client.getState()).toBe("initializing"); + + resolveSecondFetch?.(); + await secondUpdate; + + expect(client.getState()).toBe("initialized"); + + resolveFirstFetch?.(); + await firstUpdate; + + expect(client.getState()).toBe("initialized"); + }); + }); + + describe("setContext warnings", () => { + it("does not warn about missing ids when updating anonymous other context", async () => { + client = new ReflagClient({ + publishableKey: "test-key-anon", + }); + const warnSpy = vi.spyOn(client.logger, "warn"); + + await client.updateOtherContext({ workspaceId: "ws-1" }); + + expect(warnSpy).not.toHaveBeenCalledWith( + "No user Id provided in context, user will be ignored", + ); + expect(warnSpy).not.toHaveBeenCalledWith( + "No company Id provided in context, company will be ignored", + ); + }); + + it("still warns when setContext replaces context without user or company ids", async () => { + client = new ReflagClient({ + publishableKey: "test-key-set-context", + }); + const warnSpy = vi.spyOn(client.logger, "warn"); + + await client.setContext({ other: { workspaceId: "ws-1" } }); + + expect(warnSpy).toHaveBeenCalledWith( + "No user Id provided in context, user will be ignored", + ); + expect(warnSpy).toHaveBeenCalledWith( + "No company Id provided in context, company will be ignored", + ); + }); }); describe("offline mode", () => { diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index 13bed37ea..a287facdf 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -28,6 +28,9 @@ import { import { version } from "../package.json"; +const useIsomorphicLayoutEffect = + typeof window === "undefined" ? useEffect : useLayoutEffect; + export type { CheckEvent, CompanyContext, @@ -668,7 +671,7 @@ export function useOnEvent( `ReflagProvider is missing and no client was provided. Please ensure your component is wrapped with a ReflagProvider.`, ); } - useLayoutEffect(() => { + useIsomorphicLayoutEffect(() => { return resolvedClient.on(event, handler); }, [resolvedClient, event, handler]); } diff --git a/packages/react-sdk/test/usage.test.tsx b/packages/react-sdk/test/usage.test.tsx index 4a0627fcd..2ec9892ce 100644 --- a/packages/react-sdk/test/usage.test.tsx +++ b/packages/react-sdk/test/usage.test.tsx @@ -2,6 +2,7 @@ import React from "react"; import { act, render, renderHook, waitFor } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; +import { renderToString } from "react-dom/server"; import { afterAll, afterEach, @@ -1134,6 +1135,35 @@ describe("useIsLoading", () => { }); describe("useOnEvent", () => { + test("does not trigger the SSR useLayoutEffect warning when used inside a provider", () => { + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const client = new ReflagClient({ + publishableKey: "test-key-ssr", + user, + company, + other, + }); + + function EventListener() { + useOnEvent("flagsUpdated", vi.fn()); + return null; + } + + renderToString( + + + , + ); + + expect( + errorSpy.mock.calls.some(([firstArg]) => + String(firstArg).includes("useLayoutEffect"), + ), + ).toBe(false); + + errorSpy.mockRestore(); + }); + test("subscribes to flagsUpdated event", async () => { const eventHandler = vi.fn(); const client = new ReflagClient({ From a8867e4663dd60d1e65cc2df270d6d300da7c7b2 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 11 Mar 2026 21:02:45 +0100 Subject: [PATCH 3/6] Clean up react-sdk test lint warnings --- packages/react-sdk/test/usage.test.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/react-sdk/test/usage.test.tsx b/packages/react-sdk/test/usage.test.tsx index 2ec9892ce..dac200747 100644 --- a/packages/react-sdk/test/usage.test.tsx +++ b/packages/react-sdk/test/usage.test.tsx @@ -1,8 +1,8 @@ import React from "react"; +import { renderToString } from "react-dom/server"; import { act, render, renderHook, waitFor } from "@testing-library/react"; import { http, HttpResponse } from "msw"; import { setupServer } from "msw/node"; -import { renderToString } from "react-dom/server"; import { afterAll, afterEach, @@ -1096,7 +1096,7 @@ describe("useIsLoading", () => { expect(getByTestId("loading-state").textContent).toBe("false"); }); - await act(async () => {}); + await act(async () => undefined); act(() => { (client as any).hooks.trigger("stateUpdated", "initializing"); @@ -1136,7 +1136,7 @@ describe("useIsLoading", () => { describe("useOnEvent", () => { test("does not trigger the SSR useLayoutEffect warning when used inside a provider", () => { - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => {}); + const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); const client = new ReflagClient({ publishableKey: "test-key-ssr", user, From 31491bb3d5e295910269795d9dd9abe2c1b04cc9 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 11 Mar 2026 21:11:52 +0100 Subject: [PATCH 4/6] Ignore stale flag fetches on context updates --- packages/browser-sdk/src/flag/flags.ts | 11 +++- packages/browser-sdk/test/flags.test.ts | 69 +++++++++++++++++++++++++ 2 files changed, 79 insertions(+), 1 deletion(-) diff --git a/packages/browser-sdk/src/flag/flags.ts b/packages/browser-sdk/src/flag/flags.ts index 1a776f6af..81ecf62b8 100644 --- a/packages/browser-sdk/src/flag/flags.ts +++ b/packages/browser-sdk/src/flag/flags.ts @@ -206,6 +206,7 @@ export class FlagsClient { private flagOverrides: FlagOverrides = {}; private flags: RawFlags = {}; private fallbackFlags: FallbackFlags = {}; + private contextFetchVersion = 0; private storage: StorageAdapter; private refreshEvents: number[] = []; @@ -291,7 +292,15 @@ export class FlagsClient { async setContext(context: ReflagContext) { this.context = context; - this.setFetchedFlags((await this.maybeFetchFlags()) || {}); + const requestVersion = ++this.contextFetchVersion; + const fetchedFlags = (await this.maybeFetchFlags()) || {}; + + if (requestVersion !== this.contextFetchVersion) { + return false; + } + + this.setFetchedFlags(fetchedFlags); + return true; } updateFlags(triggerEvent = true) { diff --git a/packages/browser-sdk/test/flags.test.ts b/packages/browser-sdk/test/flags.test.ts index b531f3536..42d2575f8 100644 --- a/packages/browser-sdk/test/flags.test.ts +++ b/packages/browser-sdk/test/flags.test.ts @@ -141,6 +141,75 @@ describe("FlagsClient", () => { expect(timeoutMs).toEqual(5000); }); + test("only applies flags from the latest context update", async () => { + const { newFlagsClient } = flagsClientFactory(); + const flagsClient = newFlagsClient(); + + let resolveFirstFetch: + | ((flags: Record) => void) + | undefined; + let resolveSecondFetch: + | ((flags: Record) => void) + | undefined; + const firstFetch = new Promise>((resolve) => { + resolveFirstFetch = resolve; + }); + const secondFetch = new Promise>((resolve) => { + resolveSecondFetch = resolve; + }); + + vi.spyOn(flagsClient as any, "maybeFetchFlags") + .mockImplementationOnce(async () => firstFetch) + .mockImplementationOnce(async () => secondFetch); + + const firstUpdate = flagsClient.setContext({ + user: { id: "user-1" }, + company: { id: "company-1" }, + other: { workspaceId: "workspace-1" }, + }); + const secondUpdate = flagsClient.setContext({ + user: { id: "user-2" }, + company: { id: "company-2" }, + other: { workspaceId: "workspace-2" }, + }); + + resolveSecondFetch?.({ + latestFlag: { + key: "latestFlag", + isEnabled: true, + targetingVersion: 2, + }, + }); + + await expect(secondUpdate).resolves.toBe(true); + expect(flagsClient.getFlags()).toEqual({ + latestFlag: { + key: "latestFlag", + isEnabled: true, + targetingVersion: 2, + isEnabledOverride: null, + }, + }); + + resolveFirstFetch?.({ + staleFlag: { + key: "staleFlag", + isEnabled: false, + targetingVersion: 1, + }, + }); + + await expect(firstUpdate).resolves.toBe(false); + expect(flagsClient.getFlags()).toEqual({ + latestFlag: { + key: "latestFlag", + isEnabled: true, + targetingVersion: 2, + isEnabledOverride: null, + }, + }); + }); + test("return fallback flags on failure (string list)", async () => { const { newFlagsClient, httpClient } = flagsClientFactory(); From 1838b5cc4a8f0aeb1fd9b6371b412fd490140bca Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 11 Mar 2026 21:21:16 +0100 Subject: [PATCH 5/6] Add loading integration test for context updates --- packages/react-sdk/test/usage.test.tsx | 74 +++++++++++++++++++++++++- 1 file changed, 73 insertions(+), 1 deletion(-) diff --git a/packages/react-sdk/test/usage.test.tsx b/packages/react-sdk/test/usage.test.tsx index dac200747..0a0ddf73f 100644 --- a/packages/react-sdk/test/usage.test.tsx +++ b/packages/react-sdk/test/usage.test.tsx @@ -16,6 +16,7 @@ import { import { ReflagClient } from "@reflag/browser-sdk"; +import { ReflagClient as SourceReflagClient } from "../../browser-sdk/src/client"; import { BootstrappedFlags, ReflagBootstrappedProps, @@ -1117,6 +1118,75 @@ describe("useIsLoading", () => { unmount(); }); + test("returns loading state during a real context update", async () => { + function LoadingState() { + return {String(useIsLoading())}; + } + + const client = new SourceReflagClient({ + publishableKey: "test-key-loading-real-context-change", + user, + company, + other, + bootstrappedFlags: { + abc: { + key: "abc", + isEnabled: true, + targetingVersion: 1, + }, + }, + }); + await client.initialize(); + + let resolveFetch: (() => void) | undefined; + const setContextPromise = new Promise((resolve) => { + resolveFetch = () => resolve(true); + }); + vi.spyOn((client as any).flagsClient, "setContext").mockImplementation( + async () => setContextPromise, + ); + + const stateUpdated = vi.fn(); + client.on("stateUpdated", stateUpdated); + + const { getByTestId, unmount } = render( + + + , + ); + + await waitFor(() => { + expect(getByTestId("loading-state").textContent).toBe("false"); + }); + + await act(async () => undefined); + + let updatePromise: Promise | undefined; + act(() => { + updatePromise = client.updateOtherContext({ + workspaceId: "workspace-1", + }); + }); + + await waitFor(() => { + expect(stateUpdated).toHaveBeenCalledWith("initializing"); + expect(getByTestId("loading-state").textContent).toBe("true"); + }); + + resolveFetch?.(); + + await act(async () => { + await updatePromise; + }); + + await waitFor(() => { + expect(stateUpdated).toHaveBeenLastCalledWith("initialized"); + expect(getByTestId("loading-state").textContent).toBe("false"); + }); + + unmount(); + }); + test("throws error when used outside provider", () => { const consoleErrorSpy = vi .spyOn(console, "error") @@ -1136,7 +1206,9 @@ describe("useIsLoading", () => { describe("useOnEvent", () => { test("does not trigger the SSR useLayoutEffect warning when used inside a provider", () => { - const errorSpy = vi.spyOn(console, "error").mockImplementation(() => undefined); + const errorSpy = vi + .spyOn(console, "error") + .mockImplementation(() => undefined); const client = new ReflagClient({ publishableKey: "test-key-ssr", user, From 2cb770d6c0555e47b77441ddf1b82f8917a4ad00 Mon Sep 17 00:00:00 2001 From: Ron Cohen Date: Wed, 11 Mar 2026 21:32:40 +0100 Subject: [PATCH 6/6] Test ReflagProvider loading on context prop updates --- packages/react-sdk/test/usage.test.tsx | 85 +++++++++++++------------- 1 file changed, 44 insertions(+), 41 deletions(-) diff --git a/packages/react-sdk/test/usage.test.tsx b/packages/react-sdk/test/usage.test.tsx index 0a0ddf73f..084994a4f 100644 --- a/packages/react-sdk/test/usage.test.tsx +++ b/packages/react-sdk/test/usage.test.tsx @@ -16,7 +16,6 @@ import { import { ReflagClient } from "@reflag/browser-sdk"; -import { ReflagClient as SourceReflagClient } from "../../browser-sdk/src/client"; import { BootstrappedFlags, ReflagBootstrappedProps, @@ -1118,73 +1117,77 @@ describe("useIsLoading", () => { unmount(); }); - test("returns loading state during a real context update", async () => { + test("returns loading state when ReflagProvider context updates", async () => { function LoadingState() { return {String(useIsLoading())}; } - const client = new SourceReflagClient({ - publishableKey: "test-key-loading-real-context-change", - user, - company, - other, - bootstrappedFlags: { - abc: { - key: "abc", - isEnabled: true, - targetingVersion: 1, - }, - }, - }); - await client.initialize(); - - let resolveFetch: (() => void) | undefined; - const setContextPromise = new Promise((resolve) => { - resolveFetch = () => resolve(true); - }); - vi.spyOn((client as any).flagsClient, "setContext").mockImplementation( - async () => setContextPromise, - ); + const initializeSpy = vi + .spyOn(ReflagClient.prototype, "initialize") + .mockResolvedValue(undefined); + let resolveSetContext: (() => void) | undefined; + const setContextPromise = new Promise((resolve) => { + resolveSetContext = resolve; + }); + const setContextSpy = vi + .spyOn(ReflagClient.prototype, "setContext") + .mockResolvedValueOnce(undefined) + .mockImplementationOnce(async function () { + (this as any).hooks.trigger("stateUpdated", "initializing"); + await setContextPromise; + (this as any).hooks.trigger("stateUpdated", "initialized"); + }); - const stateUpdated = vi.fn(); - client.on("stateUpdated", stateUpdated); + const initialContext = { user, company, other }; + const updatedContext = { + ...initialContext, + other: { ...other, workspaceId: "workspace-1" }, + }; - const { getByTestId, unmount } = render( - + const { getByTestId, rerender, unmount } = render( + - , + , ); await waitFor(() => { expect(getByTestId("loading-state").textContent).toBe("false"); }); - await act(async () => undefined); + await waitFor(() => { + expect(setContextSpy).toHaveBeenCalledTimes(1); + }); - let updatePromise: Promise | undefined; act(() => { - updatePromise = client.updateOtherContext({ - workspaceId: "workspace-1", - }); + rerender( + + + , + ); }); await waitFor(() => { - expect(stateUpdated).toHaveBeenCalledWith("initializing"); + expect(setContextSpy).toHaveBeenCalledTimes(2); expect(getByTestId("loading-state").textContent).toBe("true"); }); - resolveFetch?.(); - - await act(async () => { - await updatePromise; - }); + resolveSetContext?.(); await waitFor(() => { - expect(stateUpdated).toHaveBeenLastCalledWith("initialized"); expect(getByTestId("loading-state").textContent).toBe("false"); }); unmount(); + initializeSpy.mockRestore(); + setContextSpy.mockRestore(); }); test("throws error when used outside provider", () => {