Skip to content

Commit d766f39

Browse files
authored
feat: adding isomorphic provider to bridge client and server (#1218)
SDK-1946 - 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 <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Adds new server/client bridge components and changes SSR/noop-client initialization semantics, which can affect hydration and initial flag state in React Server Components apps. Also adjusts build configuration to avoid bundling issues between server and client entrypoints. > > **Overview** > Introduces an **isomorphic provider path for React Server Components**: `LDIsomorphicProvider` (server component) computes bootstrap data via `session.allFlagsState({ clientSideOnly: true }).toJSON()` and renders a new `'use client'` `LDIsomorphicClientProvider` that initializes the browser SDK with that bootstrap. > > Updates the SSR `createNoopClient` stub to always report `getInitializationState()` as `'initializing'` (while still treating presence of bootstrap as `isReady()`), and adds coverage for the new providers plus updated noop-client expectations. > > Refreshes the `server-only` example and migration docs to demonstrate server-evaluated flag bootstrap (adds required `LAUNCHDARKLY_CLIENT_SIDE_ID`, a client component using `useBoolVariation`, and updated e2e assertion), and tweaks packaging (`tsup`/`tsconfig` path mapping + server bundle `external`) so the server entry can reference the client package without bundling conflicts. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 9fa80fd. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY --> <!-- devin-review-badge-begin --> --- <a href="https://app.devin.ai/review/launchdarkly/js-core/pull/1218" target="_blank"> <picture> <source media="(prefers-color-scheme: dark)" srcset="https://static.devin.ai/assets/gh-open-in-devin-review-dark.svg?v=1"> <img src="https://static.devin.ai/assets/gh-open-in-devin-review-light.svg?v=1" alt="Open with Devin"> </picture> </a> <!-- devin-review-badge-end -->
1 parent 9019808 commit d766f39

File tree

19 files changed

+540
-44
lines changed

19 files changed

+540
-44
lines changed

packages/sdk/react/__tests__/client/createNoopClient.test.ts

Lines changed: 4 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -207,19 +207,10 @@ describe('handles edge cases gracefully', () => {
207207
expect(detail.variationIndex).toBeNull();
208208
});
209209

210-
it('reports initialization state as complete when bootstrap is provided', () => {
211-
const client = createNoopClient({});
212-
expect(client.getInitializationState()).toBe('complete');
213-
});
214-
215-
it('reports initialization state as complete when bootstrap has flags', () => {
216-
const client = createNoopClient({ 'my-flag': true });
217-
expect(client.getInitializationState()).toBe('complete');
218-
});
219-
220-
it('reports initialization state as initializing when bootstrap is not provided', () => {
221-
const client = createNoopClient();
222-
expect(client.getInitializationState()).toBe('initializing');
210+
it('always reports initialization state as initializing', () => {
211+
expect(createNoopClient({}).getInitializationState()).toBe('initializing');
212+
expect(createNoopClient({ 'my-flag': true }).getInitializationState()).toBe('initializing');
213+
expect(createNoopClient().getInitializationState()).toBe('initializing');
223214
});
224215

225216
it('isReady returns true when bootstrap is provided', () => {
Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import React from 'react';
2+
3+
import { createNoopClient } from '../../../src/client/createNoopClient';
4+
import { LDIsomorphicClientProvider } from '../../../src/client/provider/LDIsomorphicClientProvider';
5+
import {
6+
createLDReactProvider,
7+
createLDReactProviderWithClient,
8+
} from '../../../src/client/provider/LDReactProvider';
9+
10+
const mockNoopClient = { noop: true };
11+
const MockProvider: React.FC<{ children: React.ReactNode }> = ({ children }) =>
12+
React.createElement('div', { 'data-testid': 'mock-provider' }, children);
13+
14+
jest.mock('../../../src/client/createNoopClient', () => ({
15+
createNoopClient: jest.fn(() => mockNoopClient),
16+
}));
17+
18+
jest.mock('../../../src/client/provider/LDReactProvider', () => ({
19+
createLDReactProvider: jest.fn(() => MockProvider),
20+
createLDReactProviderWithClient: jest.fn(() => MockProvider),
21+
}));
22+
23+
// Mock useRef to work outside React's render context.
24+
let refStore: { current: unknown } = { current: null };
25+
26+
const defaultProps = {
27+
clientSideId: 'client-id-123',
28+
context: { kind: 'user' as const, key: 'user-1' },
29+
bootstrap: { 'my-flag': true, $flagsState: {}, $valid: true },
30+
children: React.createElement('span', null, 'child'),
31+
};
32+
33+
beforeEach(() => {
34+
jest.clearAllMocks();
35+
refStore = { current: null };
36+
jest.spyOn(React, 'useRef').mockImplementation(() => refStore);
37+
});
38+
39+
// The test environment is node (no window), so SSR path is always taken.
40+
it('creates a noop client with bootstrap on the server', () => {
41+
LDIsomorphicClientProvider(defaultProps);
42+
43+
expect(createNoopClient).toHaveBeenCalledWith(defaultProps.bootstrap);
44+
expect(createLDReactProviderWithClient).toHaveBeenCalledWith(mockNoopClient, undefined);
45+
expect(createLDReactProvider).not.toHaveBeenCalled();
46+
});
47+
48+
it('does not re-initialize the provider on subsequent renders', () => {
49+
LDIsomorphicClientProvider(defaultProps);
50+
expect(createLDReactProviderWithClient).toHaveBeenCalledTimes(1);
51+
52+
// Second render — provider ref is already populated, so factories should not be called again.
53+
jest.clearAllMocks();
54+
LDIsomorphicClientProvider(defaultProps);
55+
expect(createNoopClient).not.toHaveBeenCalled();
56+
expect(createLDReactProviderWithClient).not.toHaveBeenCalled();
57+
});
58+
59+
describe('given a browser environment (window defined)', () => {
60+
let originalWindow: typeof globalThis.window;
61+
62+
beforeEach(() => {
63+
originalWindow = globalThis.window;
64+
// @ts-ignore — simulate browser
65+
globalThis.window = {};
66+
});
67+
68+
afterEach(() => {
69+
// @ts-ignore
70+
globalThis.window = originalWindow;
71+
});
72+
73+
it('creates a real provider with bootstrap on the client', () => {
74+
LDIsomorphicClientProvider(defaultProps);
75+
76+
expect(createLDReactProvider).toHaveBeenCalledWith(
77+
defaultProps.clientSideId,
78+
defaultProps.context,
79+
{ bootstrap: defaultProps.bootstrap, reactContext: undefined },
80+
);
81+
expect(createNoopClient).not.toHaveBeenCalled();
82+
});
83+
84+
it('forwards options merged with bootstrap to createLDReactProvider', () => {
85+
const options = { deferInitialization: true };
86+
87+
LDIsomorphicClientProvider({ ...defaultProps, options });
88+
89+
expect(createLDReactProvider).toHaveBeenCalledWith(
90+
defaultProps.clientSideId,
91+
defaultProps.context,
92+
{ deferInitialization: true, bootstrap: defaultProps.bootstrap, reactContext: undefined },
93+
);
94+
});
95+
});
Lines changed: 132 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,132 @@
1+
import React from 'react';
2+
3+
import type { LDServerSession } from '../../src/server/LDClient';
4+
import { LDIsomorphicProvider } from '../../src/server/LDIsomorphicProvider';
5+
6+
function makeMockSession(overrides?: Partial<LDServerSession>): LDServerSession {
7+
const bootstrapJson = { 'my-flag': true, $flagsState: {}, $valid: true };
8+
9+
return {
10+
initialized: jest.fn(() => true),
11+
getContext: jest.fn(() => ({ kind: 'user', key: 'test-user' })),
12+
boolVariation: jest.fn(),
13+
numberVariation: jest.fn(),
14+
stringVariation: jest.fn(),
15+
jsonVariation: jest.fn(),
16+
boolVariationDetail: jest.fn(),
17+
numberVariationDetail: jest.fn(),
18+
stringVariationDetail: jest.fn(),
19+
jsonVariationDetail: jest.fn(),
20+
allFlagsState: jest.fn(() =>
21+
Promise.resolve({
22+
valid: true,
23+
getFlagValue: jest.fn(),
24+
getFlagReason: jest.fn(),
25+
allValues: jest.fn(() => ({})),
26+
toJSON: jest.fn(() => bootstrapJson),
27+
}),
28+
),
29+
...overrides,
30+
} as unknown as LDServerSession;
31+
}
32+
33+
it('calls allFlagsState with clientSideOnly and passes toJSON as bootstrap', async () => {
34+
const session = makeMockSession();
35+
36+
const result = await LDIsomorphicProvider({
37+
session,
38+
clientSideId: 'client-id-123',
39+
children: React.createElement('div'),
40+
});
41+
42+
expect(session.allFlagsState).toHaveBeenCalledWith({ clientSideOnly: true });
43+
44+
// The async component returns a React element whose props contain the bootstrap data.
45+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
46+
const element = result as any;
47+
expect(element.props.bootstrap).toEqual({
48+
'my-flag': true,
49+
$flagsState: {},
50+
$valid: true,
51+
});
52+
});
53+
54+
it('passes session context to the client provider', async () => {
55+
const context = { kind: 'user' as const, key: 'ctx-abc' };
56+
const session = makeMockSession({
57+
getContext: jest.fn(() => context),
58+
});
59+
60+
const result = await LDIsomorphicProvider({
61+
session,
62+
clientSideId: 'client-id-123',
63+
children: React.createElement('div'),
64+
});
65+
66+
expect(session.getContext).toHaveBeenCalled();
67+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
68+
const element = result as any;
69+
expect(element.props.context).toEqual(context);
70+
});
71+
72+
it('forwards clientSideId to the client provider', async () => {
73+
const session = makeMockSession();
74+
75+
const result = await LDIsomorphicProvider({
76+
session,
77+
clientSideId: 'my-client-side-id',
78+
children: React.createElement('div'),
79+
});
80+
81+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
82+
const element = result as any;
83+
expect(element.props.clientSideId).toBe('my-client-side-id');
84+
});
85+
86+
it('forwards options to the client provider', async () => {
87+
const session = makeMockSession();
88+
const options = { deferInitialization: true };
89+
90+
const result = await LDIsomorphicProvider({
91+
session,
92+
clientSideId: 'client-id-123',
93+
// @ts-ignore — minimal options mock
94+
options,
95+
children: React.createElement('div'),
96+
});
97+
98+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
99+
const element = result as any;
100+
expect(element.props.options).toEqual(options);
101+
});
102+
103+
it('falls back to undefined bootstrap when allFlagsState throws', async () => {
104+
const session = makeMockSession({
105+
allFlagsState: jest.fn(() => Promise.reject(new Error('client not initialized'))),
106+
});
107+
108+
const result = await LDIsomorphicProvider({
109+
session,
110+
clientSideId: 'client-id-123',
111+
children: React.createElement('div'),
112+
});
113+
114+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
115+
const element = result as any;
116+
expect(element.props.bootstrap).toBeUndefined();
117+
});
118+
119+
it('passes children to the client provider', async () => {
120+
const session = makeMockSession();
121+
const child = React.createElement('span', null, 'hello');
122+
123+
const result = await LDIsomorphicProvider({
124+
session,
125+
clientSideId: 'client-id-123',
126+
children: child,
127+
});
128+
129+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
130+
const element = result as any;
131+
expect(element.props.children).toEqual(child);
132+
});

packages/sdk/react/__tests__/server/useLDServerSession.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ let mockCacheStore: { session: LDServerSession | null } = { session: null };
99

1010
jest.mock('react', () => ({
1111
cache: (_fn: unknown) => () => mockCacheStore,
12+
createContext: jest.fn(),
1213
}));
1314

1415
beforeEach(() => {

packages/sdk/react/examples/server-only/README.md

Lines changed: 20 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,17 @@ We've built a simple web application that demonstrates how the LaunchDarkly Reac
44
React Server Components (RSC). The app evaluates a feature flag on the server and renders the
55
result — no client-side JavaScript required.
66

7-
The demo also shows how `createLDServerSession` and `useLDServerSession` work together to provide
7+
The demo also shows 2 ways to use react server side rendering:
8+
9+
1. Using `createLDServerSession` and `useLDServerSession` to provide
810
per-request session isolation: every HTTP request creates its own `LDServerSession` bound to
911
that request's user context. Nested Server Components access the session through React's `cache()`
1012
without any prop drilling.
1113

14+
2. Using the `LDIsomorphicProvider` to bootstrap the browser SDK with server-evaluated flag values. This
15+
eliminates the client-side flag fetch waterfall — the browser SDK starts immediately with real
16+
values.
17+
1218
Below, you'll find the build procedure. For more comprehensive instructions, you can visit your
1319
[Quickstart page](https://app.launchdarkly.com/quickstart#/) or the
1420
[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.
2228
| `ldBaseClient` (module-level) | A singleton Node SDK client, initialized once per process. Shared across all requests. |
2329
| `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()`. |
2430
| `useLDServerSession()` (in `App.tsx`) | Retrieves the session from React's per-request cache. No props needed — React isolates each request automatically. |
31+
| `LDIsomorphicProvider` | Wraps the app to bootstrap the browser SDK with server-evaluated flags. |
32+
| `BootstrappedClient` (in `App.tsx`) | A `'use client'` component that evaluates a flag via the bootstrapped browser SDK. |
2533

2634
To observe per-request isolation, open browser tabs with different `context` query parameters.
2735
Each tab gets a completely independent `LDServerSession` with its own context:
@@ -43,7 +51,15 @@ instead of query parameters.
4351
export LAUNCHDARKLY_SDK_KEY="my-sdk-key"
4452
```
4553

46-
2. If there is an existing boolean feature flag in your LaunchDarkly project that you want to
54+
2. Set the `LAUNCHDARKLY_CLIENT_SIDE_ID` environment variable to enable bootstrap.
55+
The server evaluates all flags and passes them to the browser SDK so flags are
56+
available immediately on the client without a network round-trip.
57+
58+
```bash
59+
export LAUNCHDARKLY_CLIENT_SIDE_ID="my-client-side-id"
60+
```
61+
62+
3. If there is an existing boolean feature flag in your LaunchDarkly project that you want to
4763
evaluate, set `LAUNCHDARKLY_FLAG_KEY`:
4864

4965
```bash
@@ -52,7 +68,7 @@ instead of query parameters.
5268

5369
Otherwise, `sample-feature` will be used by default.
5470

55-
3. On the command line, run:
71+
4. On the command line, run:
5672

5773
```bash
5874
yarn dev
@@ -62,7 +78,7 @@ instead of query parameters.
6278
spec message, current context name, and a full-page background: green when the
6379
flag is on, or grey when off.
6480

65-
4. To simulate a different user, append the `?context=` query parameter:
81+
5. To simulate a different user, append the `?context=` query parameter:
6682

6783
| URL | Context |
6884
|-----|---------|

packages/sdk/react/examples/server-only/app/App.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { useLDServerSession } from '@launchdarkly/react-sdk/server';
22

3+
import BootstrappedClient from './BootstrappedClient';
4+
35
// The flag key to evaluate. Override with the LAUNCHDARKLY_FLAG_KEY environment variable.
46
const flagKey = process.env.LAUNCHDARKLY_FLAG_KEY || 'sample-feature';
57

@@ -27,8 +29,13 @@ export default async function App() {
2729

2830
return (
2931
<div className={`app ${flagValue ? 'app--on' : 'app--off'}`}>
30-
<p>{`The ${flagKey} feature flag evaluates to ${String(flagValue)}.`}</p>
32+
<p className="flag-key">Feature flag: {flagKey}</p>
3133
<p className="context">Context: {ctx.name ?? ctx.key}</p>
34+
<p>
35+
<strong>Server:</strong> feature flag evaluates to {String(flagValue)} (server-side
36+
rendered).
37+
</p>
38+
<BootstrappedClient flagKey={flagKey} />
3239
</div>
3340
);
3441
}
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
'use client';
2+
3+
import { useBoolVariation } from '@launchdarkly/react-sdk';
4+
5+
/**
6+
* Client component that evaluates a flag via the bootstrapped react clientSDK.
7+
* The LDIsomorphicProvider evaluates all flags on the server and passes them
8+
* to the react client SDK as bootstrap data.
9+
*/
10+
export default function BootstrappedClient({ flagKey }: { flagKey: string }) {
11+
const flagValue = useBoolVariation(flagKey, false);
12+
13+
return (
14+
<p>
15+
<strong>Client:</strong> feature flag evaluates to {String(flagValue)} (bootstrapped).
16+
</p>
17+
);
18+
}

0 commit comments

Comments
 (0)