diff --git a/.changeset/green-flowers-suspend.md b/.changeset/green-flowers-suspend.md new file mode 100644 index 000000000..9ddb8e772 --- /dev/null +++ b/.changeset/green-flowers-suspend.md @@ -0,0 +1,5 @@ +--- +"@reflag/react-sdk": minor +--- + +Add Suspense support for `useFlag` while flags are loading via provider-level and per-hook `suspense` options. diff --git a/packages/react-sdk/README.md b/packages/react-sdk/README.md index d98f9d77e..8b3f51724 100644 --- a/packages/react-sdk/README.md +++ b/packages/react-sdk/README.md @@ -477,7 +477,9 @@ This App Router approach leverages Server Components for server-side flag fetchi ## `` component -The `` is a lower-level component that accepts a pre-initialized `ReflagClient` instance. This is useful for advanced use cases where you need full control over client initialization or want to share a client instance across multiple parts of your application. +The `` is a lower-level component that accepts a `ReflagClient` instance. This is useful for advanced use cases where you need full control over client initialization or want to share a client instance across multiple parts of your application. + +In most cases you should initialize the client before rendering this provider. If you pass an idle client and enable `suspense`, a `useFlag()` call that suspends can initialize the client on demand; without Suspense, you must initialize the client yourself. ### Usage @@ -509,8 +511,9 @@ function App() { The `ReflagClientProvider` accepts the following props: -- `client`: A pre-initialized `ReflagClient` instance +- `client`: A `ReflagClient` instance. Prefer passing an already-initialized client; idle clients are initialized on demand only by suspense-enabled `useFlag()` calls. - `loadingComponent`: Optional React component to show while the client is initializing (same as `ReflagProvider`) +- `suspense`: Optional. Set to `true` to make `useFlag()` suspend while the client is loading > [!Note] > Most applications should use `ReflagProvider` or `ReflagBootstrappedProvider` instead of `ReflagClientProvider`. Only use this component when you need the advanced control it provides. @@ -549,6 +552,7 @@ The `` initializes the Reflag SDK, fetches flags and starts list - `staleTimeMs`: Maximum time (in milliseconds) that stale flags will be returned if `staleWhileRevalidate` is true and new flags cannot be fetched. - `offline`: Provide this option when testing or in local development environments to avoid contacting Reflag servers. - `loadingComponent` lets you specify an React component to be rendered instead of the children while the Reflag provider is initializing. If you want more control over loading screens, `useFlag()` and `useIsLoading` returns `isLoading` which you can use to customize the loading experience. +- `suspense`: Set to `true` to make `useFlag()` suspend while the provider is loading. Wrap components that call `useFlag()` in React `` boundaries and omit `loadingComponent` if you want Suspense fallbacks to control loading UI. - `enableTracking`: Set to `false` to stop sending tracking events and user/company updates to Reflag. Useful when you're impersonating a user (defaults to `true`), - `enableLiveFlagUpdates`: Enables live flag updates over SSE. Defaults to `true` in the React SDK. - `apiBaseUrl`: Optional base URL for the Reflag API. This also controls the SSE origin used for live flag updates and automated feedback, @@ -656,6 +660,23 @@ function StartHuddleButton() { } ``` +#### Suspense loading + +Enable `suspense` on the provider to have `useFlag()` throw a promise while `isLoading` is true. The nearest `` boundary will render its fallback until flags are ready. + +```tsx +import { Suspense } from "react"; +import { ReflagProvider } from "@reflag/react-sdk"; + + + }> + + +; +``` + +You can also opt in for a single call with `useFlag("huddle", { suspense: true })`, or opt out inside a suspense-enabled provider with `{ suspense: false }`. + ### `useOptInFlags()` and `useSetOptIn()` Use these hooks to build an end-user opt-in UI for flags where opt-in is enabled in Reflag. diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/README.md b/packages/react-sdk/dev/nextjs-bootstrap-demo/README.md index bdf6c2122..39d66a744 100644 --- a/packages/react-sdk/dev/nextjs-bootstrap-demo/README.md +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/README.md @@ -1,9 +1,18 @@ 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. +The purpose of this project is to demonstrate usage integration with the Reflag React SDK using server-side bootstrapping, with `useFlag` wrapped in a React Suspense boundary for any later client-side loading states. ## Getting Started +Configure both SDK keys in `.env.local`: + +```bash +REFLAG_SECRET_KEY=sec_... +REFLAG_PUBLISHABLE_KEY=pub_... +``` + +The example logs a warning and uses offline mode for the affected SDK if either key is missing. + Run the development server: ```bash diff --git a/packages/react-sdk/dev/nextjs-bootstrap-demo/app/client.ts b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/client.ts index 1e8663f48..d7223c7e6 100644 --- a/packages/react-sdk/dev/nextjs-bootstrap-demo/app/client.ts +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/client.ts @@ -1,10 +1,29 @@ import { ReflagClient as ReflagNodeClient } from "@reflag/node-sdk"; const secretKey = process.env.REFLAG_SECRET_KEY; +export const publishableKey = process.env.REFLAG_PUBLISHABLE_KEY || ""; +export const secretKeyConfigured = Boolean(secretKey); const offline = process.env.CI === "true" || !secretKey; declare global { var serverClient: ReflagNodeClient; + var reflagDemoEnvironmentWarningsShown: boolean | undefined; +} + +function warnAboutMissingKeys() { + if (globalThis.reflagDemoEnvironmentWarningsShown) return; + + if (!secretKey) { + console.warn( + "[Reflag demo] REFLAG_SECRET_KEY is missing; server-side flag evaluation will run in offline mode.", + ); + } + if (!publishableKey) { + console.warn( + "[Reflag demo] REFLAG_PUBLISHABLE_KEY is missing; the browser SDK will run in offline mode.", + ); + } + globalThis.reflagDemoEnvironmentWarningsShown = true; } /** @@ -13,6 +32,8 @@ declare global { * @returns The server client. */ export async function getServerClient() { + warnAboutMissingKeys(); + if (!globalThis.serverClient) { globalThis.serverClient = new ReflagNodeClient({ secretKey, 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 4d6775991..1d822eb13 100644 --- a/packages/react-sdk/dev/nextjs-bootstrap-demo/app/layout.tsx +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/layout.tsx @@ -2,9 +2,10 @@ import type { Metadata } from "next"; import { Inter } from "next/font/google"; import "./globals.css"; +import { EnvironmentWarnings } from "@/components/EnvironmentWarnings"; import { ReflagBootstrappedProvider } from "@reflag/react-sdk"; -import { getServerClient } from "./client"; +import { getServerClient, publishableKey, secretKeyConfigured } from "./client"; const inter = Inter({ subsets: ["latin"] }); @@ -13,8 +14,6 @@ export const metadata: Metadata = { description: "Generated by create next app", }; -const publishableKey = process.env.REFLAG_PUBLISHABLE_KEY || ""; - export default async function RootLayout({ children, }: Readonly<{ @@ -37,9 +36,16 @@ export default async function RootLayout({ 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 index 470bb8381..a2e798950 100644 --- a/packages/react-sdk/dev/nextjs-bootstrap-demo/app/page.tsx +++ b/packages/react-sdk/dev/nextjs-bootstrap-demo/app/page.tsx @@ -1,7 +1,19 @@ import Image from "next/image"; +import { Suspense } from "react"; import { Flags } from "@/components/Flags"; +function FlagsFallback() { + return ( +
+

Loading Reflag flags...

+
+        ...
+      
+
+ ); +} + export default async function Home() { return (
@@ -37,7 +49,9 @@ export default async function Home() { /> - + }> + +
{ + if (!secretKeyConfigured) { + console.warn( + "[Reflag demo] REFLAG_SECRET_KEY is missing; server-side flag evaluation is running in offline mode.", + ); + } + if (!publishableKeyConfigured) { + console.warn( + "[Reflag demo] REFLAG_PUBLISHABLE_KEY is missing; the browser SDK is running in offline mode.", + ); + } + }, [publishableKeyConfigured, secretKeyConfigured]); + + return null; +} diff --git a/packages/react-sdk/dev/nextjs-flag-demo/README.md b/packages/react-sdk/dev/nextjs-flag-demo/README.md index bdf6c2122..0e847b7a4 100644 --- a/packages/react-sdk/dev/nextjs-flag-demo/README.md +++ b/packages/react-sdk/dev/nextjs-flag-demo/README.md @@ -1,6 +1,6 @@ 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. +The purpose of this project is to demonstrate usage integration with the Reflag React SDK, including `useFlag` loading through React Suspense in a server-rendered Next.js App Router page. ## Getting Started diff --git a/packages/react-sdk/dev/nextjs-flag-demo/app/page.tsx b/packages/react-sdk/dev/nextjs-flag-demo/app/page.tsx index 571f08cea..405bb9ee8 100644 --- a/packages/react-sdk/dev/nextjs-flag-demo/app/page.tsx +++ b/packages/react-sdk/dev/nextjs-flag-demo/app/page.tsx @@ -1,7 +1,19 @@ import Image from "next/image"; +import { Suspense } from "react"; import { Flags } from "@/components/Flags"; +function FlagsFallback() { + return ( +
+

Loading Reflag flags...

+
+        ...
+      
+
+ ); +} + export default function Home() { return (
@@ -37,7 +49,9 @@ export default function Home() { />
- + }> + +
{ }, }} fallbackFlags={["fallback-feature"]} + offline={!publishableKey} + suspense > {children} diff --git a/packages/react-sdk/src/index.tsx b/packages/react-sdk/src/index.tsx index b7e55e8dd..aeeed2f5f 100644 --- a/packages/react-sdk/src/index.tsx +++ b/packages/react-sdk/src/index.tsx @@ -163,6 +163,12 @@ export type ReflagPropsBase = { */ initialLoading?: boolean; + /** + * Set to `true` to make `useFlag` suspend while the client is loading. + * Components that call `useFlag` must be wrapped in a React `` boundary. + */ + suspense?: boolean; + /** * A custom logger to use for SDK logs. * Use this for advanced control or filtering of SDK logs. @@ -252,9 +258,50 @@ function useReflagClient(initOptions: InitOptions & { debug?: boolean }) { return reflagClients.get(publishableKey)!; } +type LoadingPromiseState = { + promise: Promise; + resolve: () => void; +}; + +const failedInitializations = new WeakSet(); + +function isClientLoading(client: ReflagClient) { + if (failedInitializations.has(client)) return false; + + const state = client.getState(); + return state === "idle" || state === "initializing"; +} + +function createLoadingPromise(client: ReflagClient): LoadingPromiseState { + let resolvePromise!: () => void; + let settled = false; + let unsubscribe: (() => void) | undefined; + + const promise = new Promise((resolve) => { + resolvePromise = resolve; + }); + + const resolve = () => { + if (settled) return; + settled = true; + unsubscribe?.(); + resolvePromise(); + }; + + unsubscribe = client.on("stateUpdated", (state) => { + if (state === "initialized" || state === "stopped") { + resolve(); + } + }); + + return { promise, resolve }; +} + type ProviderContextType = { isLoading: boolean; client: ReflagClient; + suspense: boolean; + getLoadingPromise: () => Promise; }; const ProviderContext = createContext(null); @@ -273,28 +320,68 @@ export function ReflagClientProvider({ client, loadingComponent, initialLoading = true, + suspense = false, children, }: ReflagClientProviderProps) { const hasInitialized = useRef(client.getState() === "initialized"); const [isLoading, setIsLoading] = useState( - hasInitialized.current ? false : initialLoading, + hasInitialized.current || failedInitializations.has(client) + ? false + : initialLoading, ); + const loadingPromiseRef = useRef(null); + + const getLoadingPromise = () => { + if (!loadingPromiseRef.current) { + loadingPromiseRef.current = createLoadingPromise(client); + } + + if (client.getState() === "idle") { + void Promise.resolve().then(() => { + if (client.getState() !== "idle") return; + return client.initialize().catch((e) => { + client.logger.error("failed to initialize client", e); + failedInitializations.add(client); + setLoading(false); + }); + }); + } + + return loadingPromiseRef.current.promise; + }; + + const setLoading = (loading: boolean) => { + if (!loading) { + loadingPromiseRef.current?.resolve(); + loadingPromiseRef.current = null; + } + + setIsLoading(loading); + }; + + useEffect(() => { + return () => { + loadingPromiseRef.current?.resolve(); + loadingPromiseRef.current = null; + }; + }, []); useOnEvent( "stateUpdated", (state) => { if (state === "initialized") { + failedInitializations.delete(client); hasInitialized.current = true; - setIsLoading(false); + setLoading(false); return; } if (state === "initializing") { - setIsLoading(hasInitialized.current || initialLoading); + setLoading(hasInitialized.current || initialLoading); return; } - setIsLoading(false); + setLoading(false); }, client, ); @@ -304,6 +391,8 @@ export function ReflagClientProvider({ value={{ isLoading, client, + suspense, + getLoadingPromise, }} > {isLoading && typeof loadingComponent !== "undefined" @@ -355,6 +444,7 @@ export function ReflagProvider({ otherContext, loadingComponent, initialLoading = true, + suspense, logger, debug, ...config @@ -399,6 +489,7 @@ export function ReflagProvider({ client={client} initialLoading={initialLoading} loadingComponent={loadingComponent} + suspense={suspense} > {children} @@ -425,6 +516,7 @@ export function ReflagBootstrappedProvider({ children, loadingComponent, initialLoading = false, + suspense, logger, debug, ...config @@ -454,6 +546,7 @@ export function ReflagBootstrappedProvider({ client={client} initialLoading={initialLoading} loadingComponent={loadingComponent} + suspense={suspense} > {children} @@ -465,11 +558,22 @@ export type RequestFeedbackOptions = Omit< "flagKey" | "featureId" >; +export type UseFlagOptions = { + /** + * Override the provider suspense setting for this `useFlag` call. + * When true, `useFlag` throws a promise while flags are loading. + */ + suspense?: boolean; +}; + /** * @deprecated use `useFlag` instead */ -export function useFeature(key: TKey) { - return useFlag(key); +export function useFeature( + key: TKey, + options?: UseFlagOptions, +) { + return useFlag(key, options); } /** @@ -483,10 +587,13 @@ export function useFeature(key: TKey) { * } * ``` */ -export function useFlag(key: TKey): TypedFlags[TKey] { - const client = useClient(); - const isLoading = useIsLoading(); - const [flag, setFlag] = useState(client.getFlag(key)); +export function useFlag( + key: TKey, + options: UseFlagOptions = {}, +): TypedFlags[TKey] { + const context = useSafeContext(); + const { client, isLoading } = context; + const [flag, setFlag] = useState(() => client.getFlag(key)); const track = () => client.track(key); const requestFeedback = (opts: RequestFeedbackOptions) => @@ -500,7 +607,15 @@ export function useFlag(key: TKey): TypedFlags[TKey] { client, ); - if (isLoading || !flag) { + if ( + isLoading && + isClientLoading(client) && + (options.suspense ?? context.suspense) + ) { + throw context.getLoadingPromise(); + } + + if (isLoading) { return { key, isLoading, diff --git a/packages/react-sdk/test/usage.test.tsx b/packages/react-sdk/test/usage.test.tsx index 3bea5e0d4..b75a62571 100644 --- a/packages/react-sdk/test/usage.test.tsx +++ b/packages/react-sdk/test/usage.test.tsx @@ -351,6 +351,33 @@ describe("", () => { }); describe("useFlag", () => { + function mockDelayedFlagsResponse() { + let resolveFlags!: () => void; + const flagsRequest = new Promise((resolve) => { + resolveFlags = resolve; + }); + const requestStarted = vi.fn(); + + server.use( + http.get(/\/features\/evaluated$/, async () => { + requestStarted(); + await flagsRequest; + return HttpResponse.json({ + success: true, + features: { + abc: { + key: "abc", + isEnabled: true, + targetingVersion: 1, + }, + }, + }); + }), + ); + + return { requestStarted, resolveFlags }; + } + test("returns a loading state initially", async () => { const { result, unmount } = renderHook(() => useFlag("huddle"), { wrapper: ({ children }) => getProvider({ children }), @@ -369,6 +396,166 @@ describe("useFlag", () => { unmount(); }); + test("suspends while loading when suspense is enabled", async () => { + const { requestStarted, resolveFlags } = mockDelayedFlagsResponse(); + + function FlaggedContent() { + const { isEnabled } = useFlag("abc"); + return {String(isEnabled)}; + } + + const { getByTestId, queryByTestId, unmount } = render( + Loading flags} + > + {getProvider({ + children: , + suspense: true, + })} + , + ); + + expect(getByTestId("suspense-fallback").textContent).toBe("Loading flags"); + expect(queryByTestId("flag-value")).toBeNull(); + + await waitFor(() => { + expect(requestStarted).toHaveBeenCalled(); + }); + + resolveFlags(); + + await waitFor(() => { + expect(getByTestId("flag-value").textContent).toBe("true"); + }); + expect(queryByTestId("suspense-fallback")).toBeNull(); + + unmount(); + }); + + test("suspends while loading when suspense is enabled for a single useFlag call", async () => { + const { requestStarted, resolveFlags } = mockDelayedFlagsResponse(); + + function FlaggedContent() { + const { isEnabled } = useFlag("abc", { suspense: true }); + return {String(isEnabled)}; + } + + const { getByTestId, queryByTestId, unmount } = render( + Loading flags} + > + {getProvider({ + children: , + })} + , + ); + + expect(getByTestId("suspense-fallback").textContent).toBe("Loading flags"); + expect(queryByTestId("flag-value")).toBeNull(); + + await waitFor(() => { + expect(requestStarted).toHaveBeenCalled(); + }); + + resolveFlags(); + + await waitFor(() => { + expect(getByTestId("flag-value").textContent).toBe("true"); + }); + expect(queryByTestId("suspense-fallback")).toBeNull(); + + unmount(); + }); + + test("does not suspend when suspense is disabled for a single useFlag call", async () => { + const { requestStarted, resolveFlags } = mockDelayedFlagsResponse(); + + function FlaggedContent() { + const { isEnabled, isLoading } = useFlag("abc", { suspense: false }); + return ( + + {String(isLoading)}:{String(isEnabled)} + + ); + } + + const { getByTestId, queryByTestId, unmount } = render( + Loading flags} + > + {getProvider({ + children: , + suspense: true, + })} + , + ); + + expect(queryByTestId("suspense-fallback")).toBeNull(); + expect(getByTestId("flag-value").textContent).toBe("true:false"); + + await waitFor(() => { + expect(requestStarted).toHaveBeenCalled(); + }); + + resolveFlags(); + + await waitFor(() => { + expect(getByTestId("flag-value").textContent).toBe("false:true"); + }); + + unmount(); + }); + + test("stops suspending if on-demand initialization fails", async () => { + const logger = { + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; + const initialize = vi + .spyOn(ReflagClient.prototype, "initialize") + .mockImplementationOnce(async function () { + (this as any).setState("initializing"); + throw new Error("init failed"); + }); + + function FlaggedContent() { + const { isEnabled, isLoading } = useFlag("abc"); + return ( + + {String(isLoading)}:{String(isEnabled)} + + ); + } + + const { getByTestId, queryByTestId, unmount } = render( + Loading flags} + > + {getProvider({ + children: , + logger, + suspense: true, + })} + , + ); + + expect(getByTestId("suspense-fallback").textContent).toBe("Loading flags"); + + await waitFor(() => { + expect(getByTestId("flag-value").textContent).toBe("false:false"); + }); + expect(queryByTestId("suspense-fallback")).toBeNull(); + expect(logger.error).toHaveBeenCalledWith( + "failed to initialize client", + expect.any(Error), + ); + + unmount(); + initialize.mockRestore(); + }); + test("finishes loading", async () => { const { result, unmount } = renderHook(() => useFlag("huddle"), { wrapper: ({ children }) => getProvider({ children }),