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/__tests__/client/provider/LDIsomorphicClientProvider.test.tsx b/packages/sdk/react/__tests__/client/provider/LDIsomorphicClientProvider.test.tsx new file mode 100644 index 0000000000..88ee462f78 --- /dev/null +++ b/packages/sdk/react/__tests__/client/provider/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, undefined); + 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, reactContext: undefined }, + ); + 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, reactContext: undefined }, + ); + }); +}); 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..190a4c70cc --- /dev/null +++ b/packages/sdk/react/__tests__/server/LDIsomorphicProvider.test.tsx @@ -0,0 +1,132 @@ +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('falls back to undefined 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).toBeUndefined(); +}); + +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); +}); 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..cdb36cee2d 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. | +| `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: @@ -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..a7fe2b2a9f 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 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'; @@ -27,8 +29,13 @@ export default async function App() { return (
-

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

+

Feature flag: {flagKey}

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

+

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

+
); } diff --git a/packages/sdk/react/examples/server-only/app/BootstrappedClient.tsx b/packages/sdk/react/examples/server-only/app/BootstrappedClient.tsx new file mode 100644 index 0000000000..2bd3b635a0 --- /dev/null +++ b/packages/sdk/react/examples/server-only/app/BootstrappedClient.tsx @@ -0,0 +1,18 @@ +'use client'; + +import { useBoolVariation } from '@launchdarkly/react-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 react client SDK as bootstrap data. + */ +export default function BootstrappedClient({ flagKey }: { flagKey: string }) { + const flagValue = useBoolVariation(flagKey, false); + + return ( +

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

+ ); +} 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/examples/server-only/app/styles.css b/packages/sdk/react/examples/server-only/app/styles.css index 7da30ae1e5..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 { +.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/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), diff --git a/packages/sdk/react/src/client/index.ts b/packages/sdk/react/src/client/index.ts index 006fe599b4..cf211e3dbe 100644 --- a/packages/sdk/react/src/client/index.ts +++ b/packages/sdk/react/src/client/index.ts @@ -4,7 +4,8 @@ export * from './LDOptions'; 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 * 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..8f7a80b56f --- /dev/null +++ b/packages/sdk/react/src/client/provider/LDIsomorphicClientProvider.tsx @@ -0,0 +1,91 @@ +'use client'; + +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'; + +/** + * 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. + * + * @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}. + * + * @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. + */ + reactContext?: React.Context; + + children: React.ReactNode; +} + +/** + * A `'use client'` provider that initializes the LaunchDarkly browser client from + * server-evaluated flag values bootstrapped by {@link LDIsomorphicProvider}. + * + * @remarks + * **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({ + clientSideId, + 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), + reactContext, + ); + } else { + providerRef.current = createLDReactProvider(clientSideId, context, { + ...options, + bootstrap, + reactContext, + }); + } + } + + 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..d197cddca1 --- /dev/null +++ b/packages/sdk/react/src/server/LDIsomorphicProvider.tsx @@ -0,0 +1,72 @@ +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 JavaScript Client SDK. + */ + clientSideId: string; + + /** + * Additional options forwarded to the underlying client provider and ultimately + * to {@link createLDReactProvider}. + * + * @remarks + * We omit the `bootstrap` and `reactContext` fields because they are not serializable + * across the RSC boundary. + */ + options?: Omit; + + children: React.ReactNode; +} + +/** + * An async React Server Component that bootstraps the LaunchDarkly browser client with + * server-evaluated flag values. + * + * @remarks + * **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. + */ +export async function LDIsomorphicProvider({ + session, + clientSideId, + options, + children, +}: LDIsomorphicProviderProps) { + let bootstrap: unknown; + try { + const flagsState = await session.allFlagsState({ clientSideOnly: true }); + bootstrap = flagsState.toJSON(); + } catch { + // If allFlagsState fails, bootstrap stays undefined. + } + + 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/temp_docs/MIGRATING.md b/packages/sdk/react/temp_docs/MIGRATING.md index c0e6ba9778..05bf0a2ede 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 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. + +### 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 | 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); },