From 9229fd1852583fe615b4bba34c8b3866ce8831d9 Mon Sep 17 00:00:00 2001 From: Linards Bulks Date: Thu, 16 Jul 2026 15:05:01 +0000 Subject: [PATCH] fix(react-sdk): don't mask existing flag values while client is initializing useFlag returned isEnabled: false and an empty config for every flag whenever the client state was "initializing", even when the client already held servable values. This flashes flag-gated UI to defaults: - ReflagBootstrappedProvider: initialize() passes through "initializing" after hydration although flags were injected at construction, so every SSR-bootstrapped page gets one guaranteed frame with all flags disabled - defeating the purpose of bootstrapping. - setContext(): the initialized -> initializing -> initialized transition around the refetch masked last-known values for the whole request duration on every context change. Only fall back to disabled defaults when the client has no value for the flag. isLoading is still exposed unchanged for consumers that want to render loading states themselves. The new regression test records every render, since waitFor-based assertions skip the transient frames where the flash happened. --- packages/react-sdk/src/index.tsx | 8 +++- packages/react-sdk/test/usage.test.tsx | 51 ++++++++++++++++++++++++++ 2 files changed, 58 insertions(+), 1 deletion(-) diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index a7a28a7c..adf76875 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -483,7 +483,13 @@ export function useFlag(key: TKey): TypedFlags[TKey] { client, ); - if (isLoading || !flag) { + // Only fall back to disabled defaults when the client has no value for the + // flag. While the client is initializing it may already hold servable + // values (bootstrapped state, cached flags, or previous-context flags + // during a setContext() refetch) — masking those with `isEnabled: false` + // flashes flag-gated UI. `isLoading` stays exposed for consumers that want + // to render loading states themselves. + if (!flag) { return { key, isLoading, diff --git a/packages/react-sdk/test/usage.test.tsx b/packages/react-sdk/test/usage.test.tsx index f3f69f4a..e4b1ca94 100644 --- a/packages/react-sdk/test/usage.test.tsx +++ b/packages/react-sdk/test/usage.test.tsx @@ -833,6 +833,57 @@ describe("useFlag with ReflagBootstrappedProvider", () => { unmount(); }); + test("never reports a bootstrapped flag as disabled while initializing", async () => { + const bootstrapFlags: BootstrappedFlags = { + context: { + user: { id: "456", name: "test" }, + company: { id: "123", name: "test" }, + other: { test: "test" }, + }, + flags: { + abc: { + key: "abc", + isEnabled: true, + targetingVersion: 1, + config: { + key: "gpt3", + payload: { model: "gpt-something", temperature: 0.5 }, + version: 2, + }, + }, + }, + }; + + // Record every render: waitFor-based assertions skip over transient + // frames, which is exactly where the value used to flash to disabled + // while initialize() passed through the "initializing" state. + const observed: { isEnabled: boolean; isLoading: boolean }[] = []; + + function Probe() { + const { isEnabled, isLoading } = useFlag("abc"); + observed.push({ isEnabled, isLoading }); + return null; + } + + const { unmount } = render( + getBootstrapProvider(bootstrapFlags, { children: }), + ); + + // Wait until initialization has fully completed (isLoading settled back + // to false) so all transient renders have happened. + await waitFor(() => { + expect(ReflagClient.prototype.initialize).toHaveBeenCalled(); + expect(observed.at(-1)?.isLoading).toBe(false); + }); + + expect(observed.length).toBeGreaterThan(0); + // The flag was bootstrapped as enabled — no render may report it + // disabled, including renders where the client is initializing. + expect(observed.every((o) => o.isEnabled)).toBe(true); + + unmount(); + }); + // Removed test "returns loading state when no flags are bootstrapped" // because ReflagBootstrappedProvider requires flags to be provided });