Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 4 additions & 13 deletions packages/sdk/react/__tests__/client/createNoopClient.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -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 },
);
});
});
132 changes: 132 additions & 0 deletions packages/sdk/react/__tests__/server/LDIsomorphicProvider.test.tsx
Original file line number Diff line number Diff line change
@@ -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>): 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);
});
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ let mockCacheStore: { session: LDServerSession | null } = { session: null };

jest.mock('react', () => ({
cache: (_fn: unknown) => () => mockCacheStore,
createContext: jest.fn(),
}));

beforeEach(() => {
Expand Down
24 changes: 20 additions & 4 deletions packages/sdk/react/examples/server-only/README.md
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In general, I think this example name should be changed to something like react-server-example. Might want to do this in a separate change since that could add a lot of distracting changes.

Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand All @@ -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:
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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 |
|-----|---------|
Expand Down
9 changes: 8 additions & 1 deletion packages/sdk/react/examples/server-only/app/App.tsx
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -19,7 +21,7 @@
const flagValue = await session.boolVariation(flagKey, false);
const ctx = session.getContext() as { name?: string; key: string };

console.log('[LaunchDarkly] Flag evaluation:', {

Check warning on line 24 in packages/sdk/react/examples/server-only/app/App.tsx

View workflow job for this annotation

GitHub Actions / build-test-react (20)

Unexpected console statement

Check warning on line 24 in packages/sdk/react/examples/server-only/app/App.tsx

View workflow job for this annotation

GitHub Actions / build-test-react (22)

Unexpected console statement
flagKey,
flagValue,
context: session.getContext(),
Expand All @@ -27,8 +29,13 @@

return (
<div className={`app ${flagValue ? 'app--on' : 'app--off'}`}>
<p>{`The ${flagKey} feature flag evaluates to ${String(flagValue)}.`}</p>
<p className="flag-key">Feature flag: {flagKey}</p>
<p className="context">Context: {ctx.name ?? ctx.key}</p>
<p>
<strong>Server:</strong> feature flag evaluates to {String(flagValue)} (server-side
rendered).
</p>
<BootstrappedClient flagKey={flagKey} />
</div>
);
}
Original file line number Diff line number Diff line change
@@ -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 (
<p>
<strong>Client:</strong> feature flag evaluates to {String(flagValue)} (bootstrapped).
</p>
);
}
Loading
Loading