From 051ff3413ba3baf6218ae18f653cd100c0f10a9e Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Mon, 23 Mar 2026 19:17:46 -0500 Subject: [PATCH 01/12] feat: adding isomorphic provider to bridge client and server This commit will also: - modify the server-only example to demonstrate how to use the isomorphic provider - modify the bundling so client and server can still bundle correcly with isomorphic components - refactor the client side NOOP client to be able to execute well during SSR --- .../server/useLDServerSession.test.ts | 1 + .../sdk/react/examples/server-only/README.md | 24 +++++- .../react/examples/server-only/app/App.tsx | 7 +- .../server-only/app/BootstrapClient.tsx | 18 +++++ .../react/examples/server-only/app/page.tsx | 26 ++++++- .../sdk/react/src/client/LDReactClient.tsx | 4 + packages/sdk/react/src/client/index.ts | 6 +- .../provider/LDIsomorphicClientProvider.tsx | 75 +++++++++++++++++++ .../react/src/server/LDIsomorphicProvider.tsx | 75 +++++++++++++++++++ packages/sdk/react/src/server/index.ts | 2 + packages/sdk/react/tsconfig.json | 5 +- packages/sdk/react/tsup.config.js | 1 + 12 files changed, 232 insertions(+), 12 deletions(-) create mode 100644 packages/sdk/react/examples/server-only/app/BootstrapClient.tsx create mode 100644 packages/sdk/react/src/client/provider/LDIsomorphicClientProvider.tsx create mode 100644 packages/sdk/react/src/server/LDIsomorphicProvider.tsx diff --git a/packages/sdk/react/__tests__/server/useLDServerSession.test.ts b/packages/sdk/react/__tests__/server/useLDServerSession.test.ts index 93d75157e2..533b98c1e0 100644 --- a/packages/sdk/react/__tests__/server/useLDServerSession.test.ts +++ b/packages/sdk/react/__tests__/server/useLDServerSession.test.ts @@ -9,6 +9,7 @@ let mockCacheStore: { session: LDServerSession | null } = { session: null }; jest.mock('react', () => ({ cache: (_fn: unknown) => () => mockCacheStore, + createContext: jest.fn(), })); beforeEach(() => { diff --git a/packages/sdk/react/examples/server-only/README.md b/packages/sdk/react/examples/server-only/README.md index 8ade0d434c..25d28e6e5f 100644 --- a/packages/sdk/react/examples/server-only/README.md +++ b/packages/sdk/react/examples/server-only/README.md @@ -4,11 +4,17 @@ We've built a simple web application that demonstrates how the LaunchDarkly Reac React Server Components (RSC). The app evaluates a feature flag on the server and renders the result — no client-side JavaScript required. -The demo also shows how `createLDServerSession` and `useLDServerSession` work together to provide +The demo also shows 2 ways to use react server side rendering: + +1. Using `createLDServerSession` and `useLDServerSession` to provide per-request session isolation: every HTTP request creates its own `LDServerSession` bound to that request's user context. Nested Server Components access the session through React's `cache()` without any prop drilling. +2. Using the `LDIsomorphicProvider` to bootstrap the browser SDK with server-evaluated flag values. This +eliminates the client-side flag fetch waterfall — the browser SDK starts immediately with real +values. + Below, you'll find the build procedure. For more comprehensive instructions, you can visit your [Quickstart page](https://app.launchdarkly.com/quickstart#/) or the [React SDK reference guide](https://docs.launchdarkly.com/sdk/client-side/react/react-web). @@ -22,6 +28,8 @@ This demo requires Node.js 18 or higher. | `ldBaseClient` (module-level) | A singleton Node SDK client, initialized once per process. Shared across all requests. | | `createLDServerSession(ldBaseClient, context)` | Called once per request in `app/page.tsx`. Binds the request context to the client and stores the session in React's `cache()`. | | `useLDServerSession()` (in `App.tsx`) | Retrieves the session from React's per-request cache. No props needed — React isolates each request automatically. | +| `LDIsomorphicProvider` | Wraps the app to bootstrap the browser SDK with server-evaluated flags. | +| `BootstrapClient` (in `App.tsx`) | A `'use client'` component that logs the bootstrap data to the browser console. | To observe per-request isolation, open browser tabs with different `context` query parameters. Each tab gets a completely independent `LDServerSession` with its own context: @@ -43,7 +51,15 @@ instead of query parameters. export LAUNCHDARKLY_SDK_KEY="my-sdk-key" ``` -2. If there is an existing boolean feature flag in your LaunchDarkly project that you want to +2. Set the `LAUNCHDARKLY_CLIENT_SIDE_ID` environment variable to enable bootstrap. + The server evaluates all flags and passes them to the browser SDK so flags are + available immediately on the client without a network round-trip. + + ```bash + export LAUNCHDARKLY_CLIENT_SIDE_ID="my-client-side-id" + ``` + +3. If there is an existing boolean feature flag in your LaunchDarkly project that you want to evaluate, set `LAUNCHDARKLY_FLAG_KEY`: ```bash @@ -52,7 +68,7 @@ instead of query parameters. Otherwise, `sample-feature` will be used by default. -3. On the command line, run: +4. On the command line, run: ```bash yarn dev @@ -62,7 +78,7 @@ instead of query parameters. spec message, current context name, and a full-page background: green when the flag is on, or grey when off. -4. To simulate a different user, append the `?context=` query parameter: +5. To simulate a different user, append the `?context=` query parameter: | URL | Context | |-----|---------| diff --git a/packages/sdk/react/examples/server-only/app/App.tsx b/packages/sdk/react/examples/server-only/app/App.tsx index 4359d0cac0..72fd787609 100644 --- a/packages/sdk/react/examples/server-only/app/App.tsx +++ b/packages/sdk/react/examples/server-only/app/App.tsx @@ -1,5 +1,7 @@ import { useLDServerSession } from '@launchdarkly/react-sdk/server'; +import BootstrapClient from './BootstrapClient'; + // The flag key to evaluate. Override with the LAUNCHDARKLY_FLAG_KEY environment variable. const flagKey = process.env.LAUNCHDARKLY_FLAG_KEY || 'sample-feature'; @@ -27,8 +29,11 @@ export default async function App() { return (
-

{`The ${flagKey} feature flag evaluates to ${String(flagValue)}.`}

+

+ Server: The {flagKey} feature flag evaluates to {String(flagValue)}. +

Context: {ctx.name ?? ctx.key}

+
); } diff --git a/packages/sdk/react/examples/server-only/app/BootstrapClient.tsx b/packages/sdk/react/examples/server-only/app/BootstrapClient.tsx new file mode 100644 index 0000000000..5b8b56d375 --- /dev/null +++ b/packages/sdk/react/examples/server-only/app/BootstrapClient.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { useBoolVariation } from '@launchdarkly/react-sdk'; + +/** + * Client component that evaluates a flag via the bootstrapped browser SDK. + * The LDIsomorphicProvider evaluates all flags on the server and passes them + * to the browser SDK as bootstrap data. + */ +export default function BootstrapClient({ flagKey }: { flagKey: string }) { + const flagValue = useBoolVariation(flagKey, false); + + return ( +

+ Client: The {flagKey} feature flag evaluates to {String(flagValue)}. +

+ ); +} diff --git a/packages/sdk/react/examples/server-only/app/page.tsx b/packages/sdk/react/examples/server-only/app/page.tsx index 55324b59b5..2310f5f7eb 100644 --- a/packages/sdk/react/examples/server-only/app/page.tsx +++ b/packages/sdk/react/examples/server-only/app/page.tsx @@ -1,11 +1,12 @@ import { init } from '@launchdarkly/node-server-sdk'; -import { createLDServerSession } from '@launchdarkly/react-sdk/server'; +import { createLDServerSession, LDIsomorphicProvider } from '@launchdarkly/react-sdk/server'; import App from './App'; // The base client is a module-level singleton — initialized once for the lifetime of the // Node.js process and shared across all incoming requests. const sdkKey = process.env.LAUNCHDARKLY_SDK_KEY || ''; +const clientSideId = process.env.LAUNCHDARKLY_CLIENT_SIDE_ID || ''; const ldBaseClient = sdkKey ? init(sdkKey) : null; // Select via ?context=sandy|jamie|alex (defaults to sandy). @@ -31,6 +32,17 @@ export default async function Home({ ); } + if (!clientSideId) { + return ( +
+

+ LaunchDarkly client-side ID is required: set the LAUNCHDARKLY_CLIENT_SIDE_ID environment + variable and try again. +

+
+ ); + } + try { await ldBaseClient.waitForInitialization({ timeout: 10 }); } catch { @@ -52,8 +64,14 @@ export default async function Home({ // Create a per-request session bound to this user's context. // createLDServerSession also stores the session in React's cache() so any Server Component - // in this render tree can retrieve it via useLDServerSession() — no prop drilling needed. - createLDServerSession(ldBaseClient, context); + // in this render tree can retrieve it via useLDServerSession(). + const session = createLDServerSession(ldBaseClient, context); - return ; + // Wrap the app with LDIsomorphicProvider to bootstrap the browser SDK with + // server-evaluated flag values. + return ( + + + + ); } diff --git a/packages/sdk/react/src/client/LDReactClient.tsx b/packages/sdk/react/src/client/LDReactClient.tsx index f2b5c1eda8..5ab44175bc 100644 --- a/packages/sdk/react/src/client/LDReactClient.tsx +++ b/packages/sdk/react/src/client/LDReactClient.tsx @@ -45,9 +45,12 @@ export function createClient( context: LDContext, options: LDReactClientOptions = {}, ): LDReactClient { +<<<<<<< HEAD // This should not happen during runtime, but some frameworks such as Next.js supports // static rendering which will attempt to render client code during build time. In these cases, // we will need to use the noop client to avoid errors. +======= +>>>>>>> c5eda353d (feat: adding isomorphic provider to bridge client and server) if (typeof window === 'undefined') { return createNoopClient(); } @@ -84,6 +87,7 @@ export function createClient( if (startCalled) { return baseClient.start(startOptions); } + initializationState = 'initializing'; startCalled = true; if (startOptions?.bootstrap) { hasBootstrap = true; diff --git a/packages/sdk/react/src/client/index.ts b/packages/sdk/react/src/client/index.ts index 006fe599b4..76a4aa02ee 100644 --- a/packages/sdk/react/src/client/index.ts +++ b/packages/sdk/react/src/client/index.ts @@ -2,9 +2,11 @@ export type * from '@launchdarkly/js-client-sdk'; export * from './LDClient'; export * from './LDOptions'; -export { LDReactContext, initLDReactContext } from './provider/LDReactContext'; +export * from './provider/LDReactContext'; export { createLDReactProvider, createLDReactProviderWithClient } from './provider/LDReactProvider'; +export { LDIsomorphicClientProvider } from './provider/LDIsomorphicClientProvider'; +export type { LDIsomorphicClientProviderProps } from './provider/LDIsomorphicClientProvider'; export { createClient } from './LDReactClient'; - +export { createNoopClient } from './createNoopClient'; export * from './deprecated-hooks'; export * from './hooks'; diff --git a/packages/sdk/react/src/client/provider/LDIsomorphicClientProvider.tsx b/packages/sdk/react/src/client/provider/LDIsomorphicClientProvider.tsx new file mode 100644 index 0000000000..9ef7affa92 --- /dev/null +++ b/packages/sdk/react/src/client/provider/LDIsomorphicClientProvider.tsx @@ -0,0 +1,75 @@ +'use client'; + +import React, { useRef } from 'react'; + +import { LDContext } from '@launchdarkly/js-client-sdk'; + +import { createNoopClient } from '../createNoopClient'; +import { LDReactProviderOptions } from '../LDOptions'; +import { createLDReactProvider, createLDReactProviderWithClient } from './LDReactProvider'; + +/** + * Props for {@link LDIsomorphicClientProvider}. + */ +export interface LDIsomorphicClientProviderProps { + /** + * The LaunchDarkly client-side ID. + */ + clientSideId: string; + + /** + * The initial context to identify with. + */ + context: LDContext; + + /** + * Bootstrap data from the server. Pass the result of `flagsState.toJSON()` obtained + * from {@link LDServerSession.allFlagsState} on the server. + * + * When provided, the client immediately uses these values before the first network + * response arrives — eliminating the flag-fetch waterfall on page load. + */ + bootstrap: unknown; + + /** + * Additional options forwarded to {@link createLDReactProvider}. + * + * The `bootstrap` field within these options will be overridden by the top-level + * `bootstrap` prop (which contains server-evaluated data). + */ + options?: Omit; + + /** + * Child components. + */ + children: React.ReactNode; +} + +/** + * A `'use client'` provider that initializes the LaunchDarkly browser client from + * server-evaluated flag values bootstrapped by {@link LDIsomorphicProvider}.J + * + * @remarks + * **NOTE:** This provider is desinged to be used in conjunction with {@link LDIsomorphicProvider} + * in a server component to compute the bootstrap data and render this provider automatically. + */ +export function LDIsomorphicClientProvider({ + clientSideId, + context, + bootstrap, + options, + children, +}: LDIsomorphicClientProviderProps) { + const providerRef = useRef | null>(null); + + if (providerRef.current === null) { + if (typeof window === 'undefined') { + providerRef.current = createLDReactProviderWithClient(createNoopClient(bootstrap as object)); + } else { + providerRef.current = createLDReactProvider(clientSideId, context, { ...options, bootstrap }); + } + } + + const LDProvider = providerRef.current; + return {children}; +} diff --git a/packages/sdk/react/src/server/LDIsomorphicProvider.tsx b/packages/sdk/react/src/server/LDIsomorphicProvider.tsx new file mode 100644 index 0000000000..5cde4cf660 --- /dev/null +++ b/packages/sdk/react/src/server/LDIsomorphicProvider.tsx @@ -0,0 +1,75 @@ +import React from 'react'; + +import { LDIsomorphicClientProvider } from '@launchdarkly/react-sdk'; +import type { LDReactProviderOptions } from '@launchdarkly/react-sdk'; + +import { LDServerSession } from './LDClient'; + +/** + * Props for {@link LDIsomorphicProvider}. + */ +export interface LDIsomorphicProviderProps { + /** + * A server session created by {@link createLDServerSession}. The session provides + * the context and all-flags state used to bootstrap the client. + */ + session: LDServerSession; + + /** + * The LaunchDarkly client-side ID used to initialize the browser SDK. + */ + clientSideId: string; + + /** + * Additional options forwarded to the underlying client provider and ultimately + * to {@link createLDReactProvider}. This allows control over `ldOptions`, + * `startOptions`, `deferInitialization`, and `reactContext`. + * + * The `bootstrap` field within these options will be overridden by server-evaluated + * flag data from the session. + */ + options?: Omit; + + /** + * Child components. Server components and client components can both be children. + */ + children: React.ReactNode; +} + +/** + * An async React Server Component that bootstraps the LaunchDarkly browser client with + * server-evaluated flag values. + * + * @remarks + * Place this component near the root of your layout (e.g. in `layout.tsx`). It evaluates + * all flags on the server, then passes the results to {@link LDBootstrapClientProvider} + * so the client-side SDK starts with real flag values. + * + * After hydration, the client-side SDK can open a streaming connection and live flag changes + * propagate normally to all `useVariation` / `useBoolVariation` etc. hooks. + * + * Server components in the same tree can continue to call `session.boolVariation(...)` etc. + * directly. Client components use the standard `useBoolVariation(...)` hooks. + * + * See the `server-only` example for how to use this component. + */ +export async function LDIsomorphicProvider({ + session, + clientSideId, + options, + children, +}: LDIsomorphicProviderProps) { + const flagsState = await session.allFlagsState({ clientSideOnly: true }); + const context = session.getContext(); + + return ( + + {children} + + ); +} diff --git a/packages/sdk/react/src/server/index.ts b/packages/sdk/react/src/server/index.ts index db59369572..2134a98986 100644 --- a/packages/sdk/react/src/server/index.ts +++ b/packages/sdk/react/src/server/index.ts @@ -1,3 +1,5 @@ export type * from './LDClient'; export type * from './LDServerBaseClient'; export * from './LDServerSession'; +export { LDIsomorphicProvider } from './LDIsomorphicProvider'; +export type { LDIsomorphicProviderProps } from './LDIsomorphicProvider'; diff --git a/packages/sdk/react/tsconfig.json b/packages/sdk/react/tsconfig.json index 569a8d23f7..90f77b3549 100644 --- a/packages/sdk/react/tsconfig.json +++ b/packages/sdk/react/tsconfig.json @@ -16,7 +16,10 @@ "stripInternal": true, "target": "ES2017", "types": ["jest", "node", "react/canary"], - "jsx": "react" + "jsx": "react", + "paths": { + "@launchdarkly/react-sdk": ["./src/client/index.ts"] + } }, "include": ["src/**/*"], "exclude": ["**/*.test.ts", "dist", "node_modules", "__tests__", "examples/**"] diff --git a/packages/sdk/react/tsup.config.js b/packages/sdk/react/tsup.config.js index 0b56491234..a9054e9438 100644 --- a/packages/sdk/react/tsup.config.js +++ b/packages/sdk/react/tsup.config.js @@ -39,6 +39,7 @@ export default defineConfig([ ...sharedOptions, entry: { server: 'src/server/index.ts' }, clean: false, + external: ['@launchdarkly/react-sdk'], esbuildOptions(opts) { mangleProps(opts); }, From 7e3d8b8398b210a9b57895fd75b8f0bbd1140c71 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Tue, 24 Mar 2026 10:18:17 -0500 Subject: [PATCH 02/12] chore: fixing example application --- packages/sdk/react/examples/server-only/app/App.tsx | 5 +++-- .../sdk/react/examples/server-only/app/BootstrapClient.tsx | 2 +- packages/sdk/react/examples/server-only/app/styles.css | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/sdk/react/examples/server-only/app/App.tsx b/packages/sdk/react/examples/server-only/app/App.tsx index 72fd787609..42b65d369e 100644 --- a/packages/sdk/react/examples/server-only/app/App.tsx +++ b/packages/sdk/react/examples/server-only/app/App.tsx @@ -29,10 +29,11 @@ export default async function App() { return (
+

Feature flag: {flagKey}

+

Context: {ctx.name ?? ctx.key}

- Server: The {flagKey} feature flag evaluates to {String(flagValue)}. + Server: feature flag evaluates to {String(flagValue)} (server-side rendered).

-

Context: {ctx.name ?? ctx.key}

); diff --git a/packages/sdk/react/examples/server-only/app/BootstrapClient.tsx b/packages/sdk/react/examples/server-only/app/BootstrapClient.tsx index 5b8b56d375..33ba7384c0 100644 --- a/packages/sdk/react/examples/server-only/app/BootstrapClient.tsx +++ b/packages/sdk/react/examples/server-only/app/BootstrapClient.tsx @@ -12,7 +12,7 @@ export default function BootstrapClient({ flagKey }: { flagKey: string }) { return (

- Client: The {flagKey} feature flag evaluates to {String(flagValue)}. + Client: feature flag evaluates to {String(flagValue)} (bootstrapped).

); } diff --git a/packages/sdk/react/examples/server-only/app/styles.css b/packages/sdk/react/examples/server-only/app/styles.css index 7da30ae1e5..9e20d85319 100644 --- a/packages/sdk/react/examples/server-only/app/styles.css +++ b/packages/sdk/react/examples/server-only/app/styles.css @@ -39,7 +39,7 @@ .app--on { background-color: #00844B; } .app--off { background-color: #373841; } -.context { +.context, .flag-key { font-size: 0.7em; opacity: 0.75; } From 29ea0c608a0b65166a4bb0e1eecf2c8bcf133d21 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Tue, 24 Mar 2026 10:43:05 -0500 Subject: [PATCH 03/12] chore: pr comments --- .../sdk/react/examples/server-only/README.md | 2 +- .../react/examples/server-only/app/App.tsx | 7 +-- ...strapClient.tsx => BootstrappedClient.tsx} | 6 +-- .../react/examples/server-only/app/styles.css | 14 +++-- .../examples/server-only/e2e/verify.spec.ts | 2 +- .../react/examples/server-only/tsconfig.json | 16 ++---- .../sdk/react/src/client/LDReactClient.tsx | 2 +- packages/sdk/react/src/client/index.ts | 3 +- packages/sdk/react/temp_docs/MIGRATING.md | 54 +++++++++++++++++++ 9 files changed, 77 insertions(+), 29 deletions(-) rename packages/sdk/react/examples/server-only/app/{BootstrapClient.tsx => BootstrappedClient.tsx} (62%) diff --git a/packages/sdk/react/examples/server-only/README.md b/packages/sdk/react/examples/server-only/README.md index 25d28e6e5f..cdb36cee2d 100644 --- a/packages/sdk/react/examples/server-only/README.md +++ b/packages/sdk/react/examples/server-only/README.md @@ -29,7 +29,7 @@ This demo requires Node.js 18 or higher. | `createLDServerSession(ldBaseClient, context)` | Called once per request in `app/page.tsx`. Binds the request context to the client and stores the session in React's `cache()`. | | `useLDServerSession()` (in `App.tsx`) | Retrieves the session from React's per-request cache. No props needed — React isolates each request automatically. | | `LDIsomorphicProvider` | Wraps the app to bootstrap the browser SDK with server-evaluated flags. | -| `BootstrapClient` (in `App.tsx`) | A `'use client'` component that logs the bootstrap data to the browser console. | +| `BootstrappedClient` (in `App.tsx`) | A `'use client'` component that evaluates a flag via the bootstrapped browser SDK. | To observe per-request isolation, open browser tabs with different `context` query parameters. Each tab gets a completely independent `LDServerSession` with its own context: diff --git a/packages/sdk/react/examples/server-only/app/App.tsx b/packages/sdk/react/examples/server-only/app/App.tsx index 42b65d369e..a7fe2b2a9f 100644 --- a/packages/sdk/react/examples/server-only/app/App.tsx +++ b/packages/sdk/react/examples/server-only/app/App.tsx @@ -1,6 +1,6 @@ import { useLDServerSession } from '@launchdarkly/react-sdk/server'; -import BootstrapClient from './BootstrapClient'; +import BootstrappedClient from './BootstrappedClient'; // The flag key to evaluate. Override with the LAUNCHDARKLY_FLAG_KEY environment variable. const flagKey = process.env.LAUNCHDARKLY_FLAG_KEY || 'sample-feature'; @@ -32,9 +32,10 @@ export default async function App() {

Feature flag: {flagKey}

Context: {ctx.name ?? ctx.key}

- Server: feature flag evaluates to {String(flagValue)} (server-side rendered). + Server: feature flag evaluates to {String(flagValue)} (server-side + rendered).

- + ); } diff --git a/packages/sdk/react/examples/server-only/app/BootstrapClient.tsx b/packages/sdk/react/examples/server-only/app/BootstrappedClient.tsx similarity index 62% rename from packages/sdk/react/examples/server-only/app/BootstrapClient.tsx rename to packages/sdk/react/examples/server-only/app/BootstrappedClient.tsx index 33ba7384c0..2bd3b635a0 100644 --- a/packages/sdk/react/examples/server-only/app/BootstrapClient.tsx +++ b/packages/sdk/react/examples/server-only/app/BootstrappedClient.tsx @@ -3,11 +3,11 @@ import { useBoolVariation } from '@launchdarkly/react-sdk'; /** - * Client component that evaluates a flag via the bootstrapped browser SDK. + * Client component that evaluates a flag via the bootstrapped react clientSDK. * The LDIsomorphicProvider evaluates all flags on the server and passes them - * to the browser SDK as bootstrap data. + * to the react client SDK as bootstrap data. */ -export default function BootstrapClient({ flagKey }: { flagKey: string }) { +export default function BootstrappedClient({ flagKey }: { flagKey: string }) { const flagValue = useBoolVariation(flagKey, false); return ( diff --git a/packages/sdk/react/examples/server-only/app/styles.css b/packages/sdk/react/examples/server-only/app/styles.css index 9e20d85319..699ead1da3 100644 --- a/packages/sdk/react/examples/server-only/app/styles.css +++ b/packages/sdk/react/examples/server-only/app/styles.css @@ -30,17 +30,21 @@ flex-direction: column; align-items: center; justify-content: center; - font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; font-size: calc(10px + 2vmin); padding: 2rem; gap: 1rem; } -.app--on { background-color: #00844B; } -.app--off { background-color: #373841; } +.app--on { + background-color: #00844b; +} +.app--off { + background-color: #373841; +} -.context, .flag-key { +.context, +.flag-key { font-size: 0.7em; opacity: 0.75; } - diff --git a/packages/sdk/react/examples/server-only/e2e/verify.spec.ts b/packages/sdk/react/examples/server-only/e2e/verify.spec.ts index 376eaeb78a..e11ffd5bbb 100644 --- a/packages/sdk/react/examples/server-only/e2e/verify.spec.ts +++ b/packages/sdk/react/examples/server-only/e2e/verify.spec.ts @@ -3,7 +3,7 @@ import { expect, test } from '@playwright/test'; test('feature flag evaluates to true', async ({ page }) => { await page.goto('/'); - await expect(page.getByText('feature flag evaluates to true', { exact: false })).toBeVisible({ + await expect(page.getByText('feature flag evaluates to true', { exact: false })).toHaveCount(2, { timeout: 10000, }); }); diff --git a/packages/sdk/react/examples/server-only/tsconfig.json b/packages/sdk/react/examples/server-only/tsconfig.json index 88102d75f3..d45e92c431 100644 --- a/packages/sdk/react/examples/server-only/tsconfig.json +++ b/packages/sdk/react/examples/server-only/tsconfig.json @@ -1,11 +1,7 @@ { "compilerOptions": { "target": "ES2017", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -23,9 +19,7 @@ } ], "paths": { - "@/*": [ - "./*" - ] + "@/*": ["./*"] } }, "include": [ @@ -35,9 +29,5 @@ ".next/types/**/*.ts", ".next/dev/types/**/*.ts" ], - "exclude": [ - "node_modules", - "e2e", - "playwright.config.ts" - ] + "exclude": ["node_modules", "e2e", "playwright.config.ts"] } diff --git a/packages/sdk/react/src/client/LDReactClient.tsx b/packages/sdk/react/src/client/LDReactClient.tsx index 5ab44175bc..a8518a8ea6 100644 --- a/packages/sdk/react/src/client/LDReactClient.tsx +++ b/packages/sdk/react/src/client/LDReactClient.tsx @@ -55,6 +55,7 @@ export function createClient( return createNoopClient(); } + const { useCamelCaseFlagKeys: shouldUseCamelCaseFlagKeys = true, ...ldOptions } = options; const baseClientOptions: LDOptions = { @@ -87,7 +88,6 @@ export function createClient( if (startCalled) { return baseClient.start(startOptions); } - initializationState = 'initializing'; startCalled = true; if (startOptions?.bootstrap) { hasBootstrap = true; diff --git a/packages/sdk/react/src/client/index.ts b/packages/sdk/react/src/client/index.ts index 76a4aa02ee..cf211e3dbe 100644 --- a/packages/sdk/react/src/client/index.ts +++ b/packages/sdk/react/src/client/index.ts @@ -2,11 +2,10 @@ export type * from '@launchdarkly/js-client-sdk'; export * from './LDClient'; export * from './LDOptions'; -export * from './provider/LDReactContext'; +export { LDReactContext, initLDReactContext } from './provider/LDReactContext'; export { createLDReactProvider, createLDReactProviderWithClient } from './provider/LDReactProvider'; export { LDIsomorphicClientProvider } from './provider/LDIsomorphicClientProvider'; export type { LDIsomorphicClientProviderProps } from './provider/LDIsomorphicClientProvider'; export { createClient } from './LDReactClient'; -export { createNoopClient } from './createNoopClient'; export * from './deprecated-hooks'; export * from './hooks'; diff --git a/packages/sdk/react/temp_docs/MIGRATING.md b/packages/sdk/react/temp_docs/MIGRATING.md index c0e6ba9778..5c082fcc94 100644 --- a/packages/sdk/react/temp_docs/MIGRATING.md +++ b/packages/sdk/react/temp_docs/MIGRATING.md @@ -209,6 +209,60 @@ The `bootstrap` data format is unchanged from the old SDK. You can pass either a object (`{ 'my-flag': true }`) or the output of `allFlagsState().toJSON()`, which includes `$flagsState` and `$valid` metadata. +--- + +## Isomorphic Provider (React Server Components) + +> **New in `@launchdarkly/react-sdk`.** + +`LDIsomorphicProvider` is an async React Server Component that evaluates all flags on the server +and bootstraps the browser SDK with those values. This eliminates the client-side flag fetch +waterfall — the browser SDK starts immediately with real values instead of defaults. + +After hydration the client SDK opens a streaming connection and live flag updates propagate +normally to all `useBoolVariation` / `useStringVariation` / etc. hooks. + +### Props + +| Prop | Type | Required | Description | +|------|------|----------|-------------| +| `session` | `LDServerSession` | Yes | A session created by `createLDServerSession`. Provides the evaluation context and all-flags state. | +| `clientSideId` | `string` | Yes | Your LaunchDarkly client-side ID. | +| `options` | `LDReactProviderOptions` | No | Additional options forwarded to the underlying client provider (e.g. `ldOptions`, `startOptions`, `deferInitialization`, `reactContext`). The `bootstrap` field is overridden automatically. | + +### Usage + +```tsx +// app/page.tsx (Server Component) +import { init } from '@launchdarkly/node-server-sdk'; +import { createLDServerSession, LDIsomorphicProvider } from '@launchdarkly/react-sdk/server'; + +const ldBaseClient = init(process.env.LAUNCHDARKLY_SDK_KEY!); + +export default async function Page() { + await ldBaseClient.waitForInitialization({ timeout: 10 }); + + const session = createLDServerSession(ldBaseClient, { + kind: 'user', + key: 'user-key', + name: 'Sandy', + }); + + return ( + + + + ); +} +``` + +Server Components inside the provider tree can call `session.boolVariation(...)` directly. +Client Components use the standard hooks (`useBoolVariation`, etc.) — they read from the +bootstrapped data on first render and receive live updates afterwards. + ## Removed APIs | Old API | Status | Replacement | From 82caf05fe639682ab5861dd859361abe30bb6c96 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Wed, 25 Mar 2026 16:20:10 -0500 Subject: [PATCH 04/12] refactor: adding noop on client side --- packages/sdk/react/__tests__/client/createNoopClient.test.ts | 2 +- packages/sdk/react/src/client/LDReactClient.tsx | 4 ---- .../react/src/client/provider/LDIsomorphicClientProvider.tsx | 4 ++-- packages/sdk/react/src/server/LDIsomorphicProvider.tsx | 2 +- 4 files changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/sdk/react/__tests__/client/createNoopClient.test.ts b/packages/sdk/react/__tests__/client/createNoopClient.test.ts index 2f31689408..4904cc3a8a 100644 --- a/packages/sdk/react/__tests__/client/createNoopClient.test.ts +++ b/packages/sdk/react/__tests__/client/createNoopClient.test.ts @@ -222,7 +222,7 @@ describe('handles edge cases gracefully', () => { expect(client.getInitializationState()).toBe('initializing'); }); - it('isReady returns true when bootstrap is provided', () => { + it('isReady returns true when bootstrap is provided', () => { const client = createNoopClient({}); expect(client.isReady()).toBe(true); }); diff --git a/packages/sdk/react/src/client/LDReactClient.tsx b/packages/sdk/react/src/client/LDReactClient.tsx index a8518a8ea6..f2b5c1eda8 100644 --- a/packages/sdk/react/src/client/LDReactClient.tsx +++ b/packages/sdk/react/src/client/LDReactClient.tsx @@ -45,17 +45,13 @@ export function createClient( context: LDContext, options: LDReactClientOptions = {}, ): LDReactClient { -<<<<<<< HEAD // This should not happen during runtime, but some frameworks such as Next.js supports // static rendering which will attempt to render client code during build time. In these cases, // we will need to use the noop client to avoid errors. -======= ->>>>>>> c5eda353d (feat: adding isomorphic provider to bridge client and server) if (typeof window === 'undefined') { return createNoopClient(); } - const { useCamelCaseFlagKeys: shouldUseCamelCaseFlagKeys = true, ...ldOptions } = options; const baseClientOptions: LDOptions = { diff --git a/packages/sdk/react/src/client/provider/LDIsomorphicClientProvider.tsx b/packages/sdk/react/src/client/provider/LDIsomorphicClientProvider.tsx index 9ef7affa92..35f0a1b3d0 100644 --- a/packages/sdk/react/src/client/provider/LDIsomorphicClientProvider.tsx +++ b/packages/sdk/react/src/client/provider/LDIsomorphicClientProvider.tsx @@ -47,10 +47,10 @@ export interface LDIsomorphicClientProviderProps { /** * A `'use client'` provider that initializes the LaunchDarkly browser client from - * server-evaluated flag values bootstrapped by {@link LDIsomorphicProvider}.J + * server-evaluated flag values bootstrapped by {@link LDIsomorphicProvider}. * * @remarks - * **NOTE:** This provider is desinged to be used in conjunction with {@link LDIsomorphicProvider} + * **NOTE:** This provider is designed to be used in conjunction with {@link LDIsomorphicProvider} * in a server component to compute the bootstrap data and render this provider automatically. */ export function LDIsomorphicClientProvider({ diff --git a/packages/sdk/react/src/server/LDIsomorphicProvider.tsx b/packages/sdk/react/src/server/LDIsomorphicProvider.tsx index 5cde4cf660..24d6d79a21 100644 --- a/packages/sdk/react/src/server/LDIsomorphicProvider.tsx +++ b/packages/sdk/react/src/server/LDIsomorphicProvider.tsx @@ -42,7 +42,7 @@ export interface LDIsomorphicProviderProps { * * @remarks * Place this component near the root of your layout (e.g. in `layout.tsx`). It evaluates - * all flags on the server, then passes the results to {@link LDBootstrapClientProvider} + * all flags on the server, then passes the results to {@link LDIsomorphicClientProvider} * so the client-side SDK starts with real flag values. * * After hydration, the client-side SDK can open a streaming connection and live flag changes From e4dd3ecc9581156cccc2f14e69b74aec07784906 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Fri, 27 Mar 2026 12:51:38 -0500 Subject: [PATCH 05/12] docs: adding clarification to example --- packages/sdk/react/__tests__/client/createNoopClient.test.ts | 2 +- packages/sdk/react/examples/server-only/next.config.ts | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/sdk/react/__tests__/client/createNoopClient.test.ts b/packages/sdk/react/__tests__/client/createNoopClient.test.ts index 4904cc3a8a..2f31689408 100644 --- a/packages/sdk/react/__tests__/client/createNoopClient.test.ts +++ b/packages/sdk/react/__tests__/client/createNoopClient.test.ts @@ -222,7 +222,7 @@ describe('handles edge cases gracefully', () => { expect(client.getInitializationState()).toBe('initializing'); }); - it('isReady returns true when bootstrap is provided', () => { + it('isReady returns true when bootstrap is provided', () => { const client = createNoopClient({}); expect(client.isReady()).toBe(true); }); diff --git a/packages/sdk/react/examples/server-only/next.config.ts b/packages/sdk/react/examples/server-only/next.config.ts index 4c8addcbae..1af3e01c2f 100644 --- a/packages/sdk/react/examples/server-only/next.config.ts +++ b/packages/sdk/react/examples/server-only/next.config.ts @@ -2,6 +2,10 @@ import type { NextConfig } from 'next'; import path from 'path'; const nextConfig: NextConfig = { + // We suppress strict mode for this example to make the render log only one + // evaluation. While it is correct to double evaluate with strict mode on, that + // behavior is not immediately obvious to some users. + reactStrictMode: false, turbopack: { root: path.resolve(__dirname, '../../../../..'), }, From cd2c876fc56813f8cfb85273c6aa84b3cfddde78 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Fri, 27 Mar 2026 13:59:14 -0500 Subject: [PATCH 06/12] test: adding tests for new providers --- .../LDIsomorphicClientProvider.test.tsx | 95 ++++++++++++++ .../server/LDIsomorphicProvider.test.tsx | 116 ++++++++++++++++++ 2 files changed, 211 insertions(+) create mode 100644 packages/sdk/react/__tests__/client/LDIsomorphicClientProvider.test.tsx create mode 100644 packages/sdk/react/__tests__/server/LDIsomorphicProvider.test.tsx diff --git a/packages/sdk/react/__tests__/client/LDIsomorphicClientProvider.test.tsx b/packages/sdk/react/__tests__/client/LDIsomorphicClientProvider.test.tsx new file mode 100644 index 0000000000..a7cbc6c3cf --- /dev/null +++ b/packages/sdk/react/__tests__/client/LDIsomorphicClientProvider.test.tsx @@ -0,0 +1,95 @@ +import React from 'react'; + +import { createNoopClient } from '../../src/client/createNoopClient'; +import { LDIsomorphicClientProvider } from '../../src/client/provider/LDIsomorphicClientProvider'; +import { + createLDReactProvider, + createLDReactProviderWithClient, +} from '../../src/client/provider/LDReactProvider'; + +const mockNoopClient = { noop: true }; +const MockProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => + React.createElement('div', { 'data-testid': 'mock-provider' }, children); + +jest.mock('../../src/client/createNoopClient', () => ({ + createNoopClient: jest.fn(() => mockNoopClient), +})); + +jest.mock('../../src/client/provider/LDReactProvider', () => ({ + createLDReactProvider: jest.fn(() => MockProvider), + createLDReactProviderWithClient: jest.fn(() => MockProvider), +})); + +// Mock useRef to work outside React's render context. +let refStore: { current: unknown } = { current: null }; + +const defaultProps = { + clientSideId: 'client-id-123', + context: { kind: 'user' as const, key: 'user-1' }, + bootstrap: { 'my-flag': true, $flagsState: {}, $valid: true }, + children: React.createElement('span', null, 'child'), +}; + +beforeEach(() => { + jest.clearAllMocks(); + refStore = { current: null }; + jest.spyOn(React, 'useRef').mockImplementation(() => refStore); +}); + +// The test environment is node (no window), so SSR path is always taken. +it('creates a noop client with bootstrap on the server', () => { + LDIsomorphicClientProvider(defaultProps); + + expect(createNoopClient).toHaveBeenCalledWith(defaultProps.bootstrap); + expect(createLDReactProviderWithClient).toHaveBeenCalledWith(mockNoopClient); + expect(createLDReactProvider).not.toHaveBeenCalled(); +}); + +it('does not re-initialize the provider on subsequent renders', () => { + LDIsomorphicClientProvider(defaultProps); + expect(createLDReactProviderWithClient).toHaveBeenCalledTimes(1); + + // Second render — provider ref is already populated, so factories should not be called again. + jest.clearAllMocks(); + LDIsomorphicClientProvider(defaultProps); + expect(createNoopClient).not.toHaveBeenCalled(); + expect(createLDReactProviderWithClient).not.toHaveBeenCalled(); +}); + +describe('given a browser environment (window defined)', () => { + let originalWindow: typeof globalThis.window; + + beforeEach(() => { + originalWindow = globalThis.window; + // @ts-ignore — simulate browser + globalThis.window = {}; + }); + + afterEach(() => { + // @ts-ignore + globalThis.window = originalWindow; + }); + + it('creates a real provider with bootstrap on the client', () => { + LDIsomorphicClientProvider(defaultProps); + + expect(createLDReactProvider).toHaveBeenCalledWith( + defaultProps.clientSideId, + defaultProps.context, + { bootstrap: defaultProps.bootstrap }, + ); + expect(createNoopClient).not.toHaveBeenCalled(); + }); + + it('forwards options merged with bootstrap to createLDReactProvider', () => { + const options = { deferInitialization: true }; + + LDIsomorphicClientProvider({ ...defaultProps, options }); + + expect(createLDReactProvider).toHaveBeenCalledWith( + defaultProps.clientSideId, + defaultProps.context, + { deferInitialization: true, bootstrap: defaultProps.bootstrap }, + ); + }); +}); diff --git a/packages/sdk/react/__tests__/server/LDIsomorphicProvider.test.tsx b/packages/sdk/react/__tests__/server/LDIsomorphicProvider.test.tsx new file mode 100644 index 0000000000..dda4cf64af --- /dev/null +++ b/packages/sdk/react/__tests__/server/LDIsomorphicProvider.test.tsx @@ -0,0 +1,116 @@ +import React from 'react'; + +import type { LDServerSession } from '../../src/server/LDClient'; +import { LDIsomorphicProvider } from '../../src/server/LDIsomorphicProvider'; + +function makeMockSession(overrides?: Partial): LDServerSession { + const bootstrapJson = { 'my-flag': true, $flagsState: {}, $valid: true }; + + return { + initialized: jest.fn(() => true), + getContext: jest.fn(() => ({ kind: 'user', key: 'test-user' })), + boolVariation: jest.fn(), + numberVariation: jest.fn(), + stringVariation: jest.fn(), + jsonVariation: jest.fn(), + boolVariationDetail: jest.fn(), + numberVariationDetail: jest.fn(), + stringVariationDetail: jest.fn(), + jsonVariationDetail: jest.fn(), + allFlagsState: jest.fn(() => + Promise.resolve({ + valid: true, + getFlagValue: jest.fn(), + getFlagReason: jest.fn(), + allValues: jest.fn(() => ({})), + toJSON: jest.fn(() => bootstrapJson), + }), + ), + ...overrides, + } as unknown as LDServerSession; +} + +it('calls allFlagsState with clientSideOnly and passes toJSON as bootstrap', async () => { + const session = makeMockSession(); + + const result = await LDIsomorphicProvider({ + session, + clientSideId: 'client-id-123', + children: React.createElement('div'), + }); + + expect(session.allFlagsState).toHaveBeenCalledWith({ clientSideOnly: true }); + + // The async component returns a React element whose props contain the bootstrap data. + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const element = result as any; + expect(element.props.bootstrap).toEqual({ + 'my-flag': true, + $flagsState: {}, + $valid: true, + }); +}); + +it('passes session context to the client provider', async () => { + const context = { kind: 'user' as const, key: 'ctx-abc' }; + const session = makeMockSession({ + getContext: jest.fn(() => context), + }); + + const result = await LDIsomorphicProvider({ + session, + clientSideId: 'client-id-123', + children: React.createElement('div'), + }); + + expect(session.getContext).toHaveBeenCalled(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const element = result as any; + expect(element.props.context).toEqual(context); +}); + +it('forwards clientSideId to the client provider', async () => { + const session = makeMockSession(); + + const result = await LDIsomorphicProvider({ + session, + clientSideId: 'my-client-side-id', + children: React.createElement('div'), + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const element = result as any; + expect(element.props.clientSideId).toBe('my-client-side-id'); +}); + +it('forwards options to the client provider', async () => { + const session = makeMockSession(); + const options = { deferInitialization: true }; + + const result = await LDIsomorphicProvider({ + session, + clientSideId: 'client-id-123', + // @ts-ignore — minimal options mock + options, + children: React.createElement('div'), + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const element = result as any; + expect(element.props.options).toEqual(options); +}); + +it('passes children to the client provider', async () => { + const session = makeMockSession(); + const child = React.createElement('span', null, 'hello'); + + const result = await LDIsomorphicProvider({ + session, + clientSideId: 'client-id-123', + children: child, + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const element = result as any; + expect(element.props.children).toEqual(child); +}); From 05ee4d0166482dc8813c6727ccfcd537ca7e5583 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Fri, 27 Mar 2026 16:33:19 -0500 Subject: [PATCH 07/12] chore: error handling --- .../server/LDIsomorphicProvider.test.tsx | 16 ++++++++++++++++ .../react/src/server/LDIsomorphicProvider.tsx | 12 ++++++++++-- 2 files changed, 26 insertions(+), 2 deletions(-) diff --git a/packages/sdk/react/__tests__/server/LDIsomorphicProvider.test.tsx b/packages/sdk/react/__tests__/server/LDIsomorphicProvider.test.tsx index dda4cf64af..93441bb4c8 100644 --- a/packages/sdk/react/__tests__/server/LDIsomorphicProvider.test.tsx +++ b/packages/sdk/react/__tests__/server/LDIsomorphicProvider.test.tsx @@ -100,6 +100,22 @@ it('forwards options to the client provider', async () => { expect(element.props.options).toEqual(options); }); +it('falls back to empty bootstrap when allFlagsState throws', async () => { + const session = makeMockSession({ + allFlagsState: jest.fn(() => Promise.reject(new Error('client not initialized'))), + }); + + const result = await LDIsomorphicProvider({ + session, + clientSideId: 'client-id-123', + children: React.createElement('div'), + }); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const element = result as any; + expect(element.props.bootstrap).toEqual({}); +}); + it('passes children to the client provider', async () => { const session = makeMockSession(); const child = React.createElement('span', null, 'hello'); diff --git a/packages/sdk/react/src/server/LDIsomorphicProvider.tsx b/packages/sdk/react/src/server/LDIsomorphicProvider.tsx index 24d6d79a21..c52fa3784d 100644 --- a/packages/sdk/react/src/server/LDIsomorphicProvider.tsx +++ b/packages/sdk/react/src/server/LDIsomorphicProvider.tsx @@ -59,14 +59,22 @@ export async function LDIsomorphicProvider({ options, children, }: LDIsomorphicProviderProps) { - const flagsState = await session.allFlagsState({ clientSideOnly: true }); + let bootstrap: unknown = {}; + try { + const flagsState = await session.allFlagsState({ clientSideOnly: true }); + bootstrap = flagsState.toJSON(); + } catch { + // If allFlagsState fails, fall back to an empty bootstrap + // so the client SDK can still initialize and fetch flags normally. + } + const context = session.getContext(); return ( {children} From 85fe0cfbf84a161f595ce85699b6d6c047f309f0 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Tue, 31 Mar 2026 16:46:12 -0500 Subject: [PATCH 08/12] chore: bot comment --- .../__tests__/client/createNoopClient.test.ts | 17 ++++------------- .../sdk/react/src/client/createNoopClient.ts | 2 +- 2 files changed, 5 insertions(+), 14 deletions(-) diff --git a/packages/sdk/react/__tests__/client/createNoopClient.test.ts b/packages/sdk/react/__tests__/client/createNoopClient.test.ts index 2f31689408..b0cc039853 100644 --- a/packages/sdk/react/__tests__/client/createNoopClient.test.ts +++ b/packages/sdk/react/__tests__/client/createNoopClient.test.ts @@ -207,19 +207,10 @@ describe('handles edge cases gracefully', () => { expect(detail.variationIndex).toBeNull(); }); - it('reports initialization state as complete when bootstrap is provided', () => { - const client = createNoopClient({}); - expect(client.getInitializationState()).toBe('complete'); - }); - - it('reports initialization state as complete when bootstrap has flags', () => { - const client = createNoopClient({ 'my-flag': true }); - expect(client.getInitializationState()).toBe('complete'); - }); - - it('reports initialization state as initializing when bootstrap is not provided', () => { - const client = createNoopClient(); - expect(client.getInitializationState()).toBe('initializing'); + it('always reports initialization state as initializing', () => { + expect(createNoopClient({}).getInitializationState()).toBe('initializing'); + expect(createNoopClient({ 'my-flag': true }).getInitializationState()).toBe('initializing'); + expect(createNoopClient().getInitializationState()).toBe('initializing'); }); it('isReady returns true when bootstrap is provided', () => { diff --git a/packages/sdk/react/src/client/createNoopClient.ts b/packages/sdk/react/src/client/createNoopClient.ts index f5603588ce..a2fa94b0a7 100644 --- a/packages/sdk/react/src/client/createNoopClient.ts +++ b/packages/sdk/react/src/client/createNoopClient.ts @@ -66,7 +66,7 @@ export function createNoopClient(bootstrap?: object): LDReactClient { return { allFlags: () => ({ ...flags }), getContext: () => undefined, - getInitializationState: () => (hasBootstrap ? 'complete' : 'initializing'), + getInitializationState: () => 'initializing', getInitializationError: () => undefined, isReady: () => hasBootstrap, boolVariation: (key: string, def: boolean) => getVariation(key, def, isBoolean), From 4fd417745f7d5c956f9c549509f5815f4e665369 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Tue, 31 Mar 2026 18:16:21 -0500 Subject: [PATCH 09/12] chore: bot comments --- .../LDIsomorphicClientProvider.test.tsx | 6 ++-- .../provider/LDIsomorphicClientProvider.tsx | 30 +++++++++++++++---- .../react/src/server/LDIsomorphicProvider.tsx | 13 ++++---- 3 files changed, 36 insertions(+), 13 deletions(-) diff --git a/packages/sdk/react/__tests__/client/LDIsomorphicClientProvider.test.tsx b/packages/sdk/react/__tests__/client/LDIsomorphicClientProvider.test.tsx index a7cbc6c3cf..cc285a5fce 100644 --- a/packages/sdk/react/__tests__/client/LDIsomorphicClientProvider.test.tsx +++ b/packages/sdk/react/__tests__/client/LDIsomorphicClientProvider.test.tsx @@ -41,7 +41,7 @@ it('creates a noop client with bootstrap on the server', () => { LDIsomorphicClientProvider(defaultProps); expect(createNoopClient).toHaveBeenCalledWith(defaultProps.bootstrap); - expect(createLDReactProviderWithClient).toHaveBeenCalledWith(mockNoopClient); + expect(createLDReactProviderWithClient).toHaveBeenCalledWith(mockNoopClient, undefined); expect(createLDReactProvider).not.toHaveBeenCalled(); }); @@ -76,7 +76,7 @@ describe('given a browser environment (window defined)', () => { expect(createLDReactProvider).toHaveBeenCalledWith( defaultProps.clientSideId, defaultProps.context, - { bootstrap: defaultProps.bootstrap }, + { bootstrap: defaultProps.bootstrap, reactContext: undefined }, ); expect(createNoopClient).not.toHaveBeenCalled(); }); @@ -89,7 +89,7 @@ describe('given a browser environment (window defined)', () => { expect(createLDReactProvider).toHaveBeenCalledWith( defaultProps.clientSideId, defaultProps.context, - { deferInitialization: true, bootstrap: defaultProps.bootstrap }, + { deferInitialization: true, bootstrap: defaultProps.bootstrap, reactContext: undefined }, ); }); }); diff --git a/packages/sdk/react/src/client/provider/LDIsomorphicClientProvider.tsx b/packages/sdk/react/src/client/provider/LDIsomorphicClientProvider.tsx index 35f0a1b3d0..7eac666605 100644 --- a/packages/sdk/react/src/client/provider/LDIsomorphicClientProvider.tsx +++ b/packages/sdk/react/src/client/provider/LDIsomorphicClientProvider.tsx @@ -5,6 +5,7 @@ import React, { useRef } from 'react'; import { LDContext } from '@launchdarkly/js-client-sdk'; import { createNoopClient } from '../createNoopClient'; +import { LDReactClientContextValue } from '../LDClient'; import { LDReactProviderOptions } from '../LDOptions'; import { createLDReactProvider, createLDReactProviderWithClient } from './LDReactProvider'; @@ -34,10 +35,21 @@ export interface LDIsomorphicClientProviderProps { /** * Additional options forwarded to {@link createLDReactProvider}. * - * The `bootstrap` field within these options will be overridden by the top-level - * `bootstrap` prop (which contains server-evaluated data). + * The `bootstrap` and `reactContext` fields within these options will be overridden by + * the top-level props. */ - options?: Omit; + options?: Omit; + + /** + * Optional custom React context for the LaunchDarkly client. Use this when you need + * multiple LaunchDarkly client instances in the same application. + * + * @remarks + * This prop is NOT serializable across the RSC boundary, so it cannot be passed via + * {@link LDIsomorphicProvider}. For multi-context setups, use this component directly + * from a `'use client'` component. + */ + reactContext?: React.Context; /** * Child components. @@ -58,15 +70,23 @@ export function LDIsomorphicClientProvider({ context, bootstrap, options, + reactContext, children, }: LDIsomorphicClientProviderProps) { const providerRef = useRef | null>(null); if (providerRef.current === null) { if (typeof window === 'undefined') { - providerRef.current = createLDReactProviderWithClient(createNoopClient(bootstrap as object)); + providerRef.current = createLDReactProviderWithClient( + createNoopClient(bootstrap as object), + reactContext, + ); } else { - providerRef.current = createLDReactProvider(clientSideId, context, { ...options, bootstrap }); + providerRef.current = createLDReactProvider(clientSideId, context, { + ...options, + bootstrap, + reactContext, + }); } } diff --git a/packages/sdk/react/src/server/LDIsomorphicProvider.tsx b/packages/sdk/react/src/server/LDIsomorphicProvider.tsx index c52fa3784d..59e4783dab 100644 --- a/packages/sdk/react/src/server/LDIsomorphicProvider.tsx +++ b/packages/sdk/react/src/server/LDIsomorphicProvider.tsx @@ -22,13 +22,16 @@ export interface LDIsomorphicProviderProps { /** * Additional options forwarded to the underlying client provider and ultimately - * to {@link createLDReactProvider}. This allows control over `ldOptions`, - * `startOptions`, `deferInitialization`, and `reactContext`. + * to {@link createLDReactProvider}. * - * The `bootstrap` field within these options will be overridden by server-evaluated - * flag data from the session. + * @remarks + * The `bootstrap` field is overridden by server-evaluated flag data from the session. + * + * The `reactContext` field is excluded because React Context objects are not serializable + * across the RSC boundary. For multi-context setups, use {@link LDIsomorphicClientProvider} + * directly from a `'use client'` component. */ - options?: Omit; + options?: Omit; /** * Child components. Server components and client components can both be children. From 71795a323d23f164a71a81b8682eb8343146b0c3 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Tue, 31 Mar 2026 19:00:14 -0500 Subject: [PATCH 10/12] chore: more bot comments --- .../{ => provider}/LDIsomorphicClientProvider.test.tsx | 10 +++++----- .../__tests__/server/LDIsomorphicProvider.test.tsx | 4 ++-- packages/sdk/react/src/server/LDIsomorphicProvider.tsx | 5 ++--- 3 files changed, 9 insertions(+), 10 deletions(-) rename packages/sdk/react/__tests__/client/{ => provider}/LDIsomorphicClientProvider.test.tsx (89%) diff --git a/packages/sdk/react/__tests__/client/LDIsomorphicClientProvider.test.tsx b/packages/sdk/react/__tests__/client/provider/LDIsomorphicClientProvider.test.tsx similarity index 89% rename from packages/sdk/react/__tests__/client/LDIsomorphicClientProvider.test.tsx rename to packages/sdk/react/__tests__/client/provider/LDIsomorphicClientProvider.test.tsx index cc285a5fce..88ee462f78 100644 --- a/packages/sdk/react/__tests__/client/LDIsomorphicClientProvider.test.tsx +++ b/packages/sdk/react/__tests__/client/provider/LDIsomorphicClientProvider.test.tsx @@ -1,21 +1,21 @@ import React from 'react'; -import { createNoopClient } from '../../src/client/createNoopClient'; -import { LDIsomorphicClientProvider } from '../../src/client/provider/LDIsomorphicClientProvider'; +import { createNoopClient } from '../../../src/client/createNoopClient'; +import { LDIsomorphicClientProvider } from '../../../src/client/provider/LDIsomorphicClientProvider'; import { createLDReactProvider, createLDReactProviderWithClient, -} from '../../src/client/provider/LDReactProvider'; +} from '../../../src/client/provider/LDReactProvider'; const mockNoopClient = { noop: true }; const MockProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => React.createElement('div', { 'data-testid': 'mock-provider' }, children); -jest.mock('../../src/client/createNoopClient', () => ({ +jest.mock('../../../src/client/createNoopClient', () => ({ createNoopClient: jest.fn(() => mockNoopClient), })); -jest.mock('../../src/client/provider/LDReactProvider', () => ({ +jest.mock('../../../src/client/provider/LDReactProvider', () => ({ createLDReactProvider: jest.fn(() => MockProvider), createLDReactProviderWithClient: jest.fn(() => MockProvider), })); diff --git a/packages/sdk/react/__tests__/server/LDIsomorphicProvider.test.tsx b/packages/sdk/react/__tests__/server/LDIsomorphicProvider.test.tsx index 93441bb4c8..190a4c70cc 100644 --- a/packages/sdk/react/__tests__/server/LDIsomorphicProvider.test.tsx +++ b/packages/sdk/react/__tests__/server/LDIsomorphicProvider.test.tsx @@ -100,7 +100,7 @@ it('forwards options to the client provider', async () => { expect(element.props.options).toEqual(options); }); -it('falls back to empty bootstrap when allFlagsState throws', async () => { +it('falls back to undefined bootstrap when allFlagsState throws', async () => { const session = makeMockSession({ allFlagsState: jest.fn(() => Promise.reject(new Error('client not initialized'))), }); @@ -113,7 +113,7 @@ it('falls back to empty bootstrap when allFlagsState throws', async () => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const element = result as any; - expect(element.props.bootstrap).toEqual({}); + expect(element.props.bootstrap).toBeUndefined(); }); it('passes children to the client provider', async () => { diff --git a/packages/sdk/react/src/server/LDIsomorphicProvider.tsx b/packages/sdk/react/src/server/LDIsomorphicProvider.tsx index 59e4783dab..5f9a77fc88 100644 --- a/packages/sdk/react/src/server/LDIsomorphicProvider.tsx +++ b/packages/sdk/react/src/server/LDIsomorphicProvider.tsx @@ -62,13 +62,12 @@ export async function LDIsomorphicProvider({ options, children, }: LDIsomorphicProviderProps) { - let bootstrap: unknown = {}; + let bootstrap: unknown; try { const flagsState = await session.allFlagsState({ clientSideOnly: true }); bootstrap = flagsState.toJSON(); } catch { - // If allFlagsState fails, fall back to an empty bootstrap - // so the client SDK can still initialize and fetch flags normally. + // If allFlagsState fails, bootstrap stays undefined. } const context = session.getContext(); From 0f76c598819f9cef247b94df21127da2cb49c0a8 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Wed, 1 Apr 2026 09:53:09 -0500 Subject: [PATCH 11/12] chore: docs pass --- .../provider/LDIsomorphicClientProvider.tsx | 20 +++++++--------- .../react/src/server/LDIsomorphicProvider.tsx | 23 ++++--------------- packages/sdk/react/temp_docs/MIGRATING.md | 4 ++-- 3 files changed, 15 insertions(+), 32 deletions(-) diff --git a/packages/sdk/react/src/client/provider/LDIsomorphicClientProvider.tsx b/packages/sdk/react/src/client/provider/LDIsomorphicClientProvider.tsx index 7eac666605..8f7a80b56f 100644 --- a/packages/sdk/react/src/client/provider/LDIsomorphicClientProvider.tsx +++ b/packages/sdk/react/src/client/provider/LDIsomorphicClientProvider.tsx @@ -27,33 +27,29 @@ export interface LDIsomorphicClientProviderProps { * Bootstrap data from the server. Pass the result of `flagsState.toJSON()` obtained * from {@link LDServerSession.allFlagsState} on the server. * - * When provided, the client immediately uses these values before the first network - * response arrives — eliminating the flag-fetch waterfall on page load. + * @remarks + * **NOTE:** This interface is meant to be used with the server component {@link LDIsomorphicProvider}. + * If you are looking to providing your own bootstrap data, you should use + * the {@link createLDReactProvider} function directly. + * */ bootstrap: unknown; /** * Additional options forwarded to {@link createLDReactProvider}. * - * The `bootstrap` and `reactContext` fields within these options will be overridden by - * the top-level props. + * @remarks + * The omitted fields are hoisted to top level options because they are not + * serializable across the RSC boundary. */ options?: Omit; /** * Optional custom React context for the LaunchDarkly client. Use this when you need * multiple LaunchDarkly client instances in the same application. - * - * @remarks - * This prop is NOT serializable across the RSC boundary, so it cannot be passed via - * {@link LDIsomorphicProvider}. For multi-context setups, use this component directly - * from a `'use client'` component. */ reactContext?: React.Context; - /** - * Child components. - */ children: React.ReactNode; } diff --git a/packages/sdk/react/src/server/LDIsomorphicProvider.tsx b/packages/sdk/react/src/server/LDIsomorphicProvider.tsx index 5f9a77fc88..d197cddca1 100644 --- a/packages/sdk/react/src/server/LDIsomorphicProvider.tsx +++ b/packages/sdk/react/src/server/LDIsomorphicProvider.tsx @@ -16,7 +16,7 @@ export interface LDIsomorphicProviderProps { session: LDServerSession; /** - * The LaunchDarkly client-side ID used to initialize the browser SDK. + * The LaunchDarkly client-side ID used to initialize the JavaScript Client SDK. */ clientSideId: string; @@ -25,17 +25,11 @@ export interface LDIsomorphicProviderProps { * to {@link createLDReactProvider}. * * @remarks - * The `bootstrap` field is overridden by server-evaluated flag data from the session. - * - * The `reactContext` field is excluded because React Context objects are not serializable - * across the RSC boundary. For multi-context setups, use {@link LDIsomorphicClientProvider} - * directly from a `'use client'` component. + * We omit the `bootstrap` and `reactContext` fields because they are not serializable + * across the RSC boundary. */ options?: Omit; - /** - * Child components. Server components and client components can both be children. - */ children: React.ReactNode; } @@ -44,15 +38,8 @@ export interface LDIsomorphicProviderProps { * server-evaluated flag values. * * @remarks - * Place this component near the root of your layout (e.g. in `layout.tsx`). It evaluates - * all flags on the server, then passes the results to {@link LDIsomorphicClientProvider} - * so the client-side SDK starts with real flag values. - * - * After hydration, the client-side SDK can open a streaming connection and live flag changes - * propagate normally to all `useVariation` / `useBoolVariation` etc. hooks. - * - * Server components in the same tree can continue to call `session.boolVariation(...)` etc. - * directly. Client components use the standard `useBoolVariation(...)` hooks. + * **NOTE:** This component is designed to be used in conjunction with {@link LDIsomorphicClientProvider} + * in a server component to compute the bootstrap data and render this provider automatically. * * See the `server-only` example for how to use this component. */ diff --git a/packages/sdk/react/temp_docs/MIGRATING.md b/packages/sdk/react/temp_docs/MIGRATING.md index 5c082fcc94..05bf0a2ede 100644 --- a/packages/sdk/react/temp_docs/MIGRATING.md +++ b/packages/sdk/react/temp_docs/MIGRATING.md @@ -216,8 +216,8 @@ object (`{ 'my-flag': true }`) or the output of `allFlagsState().toJSON()`, whic > **New in `@launchdarkly/react-sdk`.** `LDIsomorphicProvider` is an async React Server Component that evaluates all flags on the server -and bootstraps the browser SDK with those values. This eliminates the client-side flag fetch -waterfall — the browser SDK starts immediately with real values instead of defaults. +and bootstraps the Client-side SDK with those values. This allows the Client-side SDK to start +immediately with real values instead of defaults. After hydration the client SDK opens a streaming connection and live flag updates propagate normally to all `useBoolVariation` / `useStringVariation` / etc. hooks. From 9fa80fd1315a5abc42cf69458ee76a3fe0c131a8 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Wed, 1 Apr 2026 11:22:37 -0500 Subject: [PATCH 12/12] chore: bot comment --- packages/sdk/react/examples/server-only/next.config.ts | 4 ---- 1 file changed, 4 deletions(-) diff --git a/packages/sdk/react/examples/server-only/next.config.ts b/packages/sdk/react/examples/server-only/next.config.ts index 1af3e01c2f..4c8addcbae 100644 --- a/packages/sdk/react/examples/server-only/next.config.ts +++ b/packages/sdk/react/examples/server-only/next.config.ts @@ -2,10 +2,6 @@ import type { NextConfig } from 'next'; import path from 'path'; const nextConfig: NextConfig = { - // We suppress strict mode for this example to make the render log only one - // evaluation. While it is correct to double evaluate with strict mode on, that - // behavior is not immediately obvious to some users. - reactStrictMode: false, turbopack: { root: path.resolve(__dirname, '../../../../..'), },