Skip to content

Commit 5defaa7

Browse files
authored
chore: refactoring react server component client (#1186)
This PR is to finalize how the react server components can access server side LDClient. A few key points: - Developers will spin up a "ld server session" which is a wrapper around the global server sdk (which should be separately managed) on every request session. This wrapper would provide some React-esque capabilities to the underlying sdk. - This sdk session will be encapsulated in a [react cache](https://react.dev/reference/react/cache) which should be cleaned after every request session. - Developers can also manage these sessions themselves as we provide the create wrapper function without having to deal with react cache. This address: SDK-2026 SDK-2021 <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Moderate risk due to a breaking redesign of the server entrypoint API (removing `createReactServerClient`/context-provider options and changing browser behavior from no-op to throw), which could affect existing integrations and bundling boundaries. > > **Overview** > Refactors the React SDK server entrypoint to use a **per-request `LDServerSession`** (context bound at creation) stored in React `cache()`, exposed via `createLDServerSession`/`useLDServerSession`, and enforces server-only usage by **throwing in browser environments** rather than returning a no-op client. > > Removes the previous `createReactServerClient` + `LDContextProvider`/`LDReactServerOptions` API, introduces `LDServerBaseClient` as a minimal structural interface, and updates docs/examples accordingly. Adds Jest coverage for session binding/caching behavior and adjusts build tooling to emit separate client/server bundles (including a `"use client"` banner for the client entry) plus small eslint/gitignore tweaks for examples. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 80eca87. 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/1186" 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 e6cf03b commit 5defaa7

14 files changed

Lines changed: 582 additions & 619 deletions

packages/sdk/react/.eslintrc.cjs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
module.exports = {
2-
ignorePatterns: ['contract-tests/next-env.d.ts'],
2+
ignorePatterns: ['contract-tests/next-env.d.ts', 'examples/server-only/next-env.d.ts'],
33
overrides: [
44
{
55
files: ['contract-tests/**/*.ts', 'contract-tests/**/*.tsx'],
Lines changed: 177 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,177 @@
1+
import { LDContext, LDFlagsStateOptions } from '@launchdarkly/js-server-sdk-common';
2+
3+
import { createLDServerSession, isServer } from '../../src/server/index';
4+
5+
const context: LDContext = { kind: 'user', key: 'test-user' };
6+
7+
function makeMockBaseClient() {
8+
return {
9+
initialized: jest.fn(() => true),
10+
boolVariation: jest.fn((_key: string, _ctx: LDContext, def: boolean) => Promise.resolve(def)),
11+
numberVariation: jest.fn((_key: string, _ctx: LDContext, def: number) => Promise.resolve(def)),
12+
stringVariation: jest.fn((_key: string, _ctx: LDContext, def: string) => Promise.resolve(def)),
13+
jsonVariation: jest.fn((_key: string, _ctx: LDContext, def: unknown) => Promise.resolve(def)),
14+
boolVariationDetail: jest.fn((_key: string, _ctx: LDContext, def: boolean) =>
15+
Promise.resolve({ value: def, variationIndex: null, reason: { kind: 'OFF' as const } }),
16+
),
17+
numberVariationDetail: jest.fn((_key: string, _ctx: LDContext, def: number) =>
18+
Promise.resolve({ value: def, variationIndex: null, reason: { kind: 'OFF' as const } }),
19+
),
20+
stringVariationDetail: jest.fn((_key: string, _ctx: LDContext, def: string) =>
21+
Promise.resolve({ value: def, variationIndex: null, reason: { kind: 'OFF' as const } }),
22+
),
23+
jsonVariationDetail: jest.fn((_key: string, _ctx: LDContext, def: unknown) =>
24+
Promise.resolve({ value: def, variationIndex: null, reason: { kind: 'OFF' as const } }),
25+
),
26+
// @ts-ignore — mock return shape matches LDFlagsState structurally
27+
allFlagsState: jest.fn((_context: LDContext, _options?: LDFlagsStateOptions) =>
28+
Promise.resolve({
29+
valid: true,
30+
getFlagValue: jest.fn(),
31+
getFlagReason: jest.fn(),
32+
allValues: jest.fn(() => ({})),
33+
toJSON: jest.fn(() => ({ $flagsState: {}, $valid: true })),
34+
}),
35+
),
36+
};
37+
}
38+
39+
it('isServer() returns true in a Node test environment', () => {
40+
expect(isServer()).toBe(true);
41+
});
42+
43+
it('getContext() returns the context passed at creation', () => {
44+
const client = makeMockBaseClient();
45+
const session = createLDServerSession(client, context);
46+
expect(session.getContext()).toEqual(context);
47+
});
48+
49+
it('initialized() delegates to the base client', () => {
50+
const client = makeMockBaseClient();
51+
client.initialized.mockReturnValue(false);
52+
const session = createLDServerSession(client, context);
53+
expect(session.initialized()).toBe(false);
54+
expect(client.initialized).toHaveBeenCalledTimes(1);
55+
});
56+
57+
it('boolVariation() calls base client with bound context', async () => {
58+
const client = makeMockBaseClient();
59+
client.boolVariation.mockResolvedValue(true);
60+
const session = createLDServerSession(client, context);
61+
const result = await session.boolVariation('my-flag', false);
62+
expect(result).toBe(true);
63+
expect(client.boolVariation).toHaveBeenCalledWith('my-flag', context, false);
64+
});
65+
66+
it('numberVariation() calls base client with bound context', async () => {
67+
const client = makeMockBaseClient();
68+
client.numberVariation.mockResolvedValue(42);
69+
const session = createLDServerSession(client, context);
70+
const result = await session.numberVariation('my-flag', 0);
71+
expect(result).toBe(42);
72+
expect(client.numberVariation).toHaveBeenCalledWith('my-flag', context, 0);
73+
});
74+
75+
it('stringVariation() calls base client with bound context', async () => {
76+
const client = makeMockBaseClient();
77+
client.stringVariation.mockResolvedValue('hello');
78+
const session = createLDServerSession(client, context);
79+
const result = await session.stringVariation('my-flag', 'default');
80+
expect(result).toBe('hello');
81+
expect(client.stringVariation).toHaveBeenCalledWith('my-flag', context, 'default');
82+
});
83+
84+
it('jsonVariation() calls base client with bound context', async () => {
85+
const client = makeMockBaseClient();
86+
const json = { key: 'value' };
87+
client.jsonVariation.mockResolvedValue(json);
88+
const session = createLDServerSession(client, context);
89+
const result = await session.jsonVariation('my-flag', {});
90+
expect(result).toEqual(json);
91+
expect(client.jsonVariation).toHaveBeenCalledWith('my-flag', context, {});
92+
});
93+
94+
it('boolVariationDetail() calls base client with bound context', async () => {
95+
const client = makeMockBaseClient();
96+
const detail = { value: true, variationIndex: 1, reason: { kind: 'RULE_MATCH' as const } };
97+
// @ts-ignore — valid LDEvaluationDetailTyped<boolean> shape; mock type is too narrow
98+
client.boolVariationDetail.mockResolvedValue(detail);
99+
const session = createLDServerSession(client, context);
100+
const result = await session.boolVariationDetail('my-flag', false);
101+
expect(result).toEqual(detail);
102+
expect(client.boolVariationDetail).toHaveBeenCalledWith('my-flag', context, false);
103+
});
104+
105+
it('numberVariationDetail() calls base client with bound context', async () => {
106+
const client = makeMockBaseClient();
107+
const detail = { value: 42, variationIndex: 1, reason: { kind: 'RULE_MATCH' as const } };
108+
// @ts-ignore — valid LDEvaluationDetailTyped<number> shape; mock type is too narrow
109+
client.numberVariationDetail.mockResolvedValue(detail);
110+
const session = createLDServerSession(client, context);
111+
const result = await session.numberVariationDetail('my-flag', 0);
112+
expect(result).toEqual(detail);
113+
expect(client.numberVariationDetail).toHaveBeenCalledWith('my-flag', context, 0);
114+
});
115+
116+
it('stringVariationDetail() calls base client with bound context', async () => {
117+
const client = makeMockBaseClient();
118+
const detail = { value: 'hello', variationIndex: 1, reason: { kind: 'RULE_MATCH' as const } };
119+
// @ts-ignore — valid LDEvaluationDetailTyped<string> shape; mock type is too narrow
120+
client.stringVariationDetail.mockResolvedValue(detail);
121+
const session = createLDServerSession(client, context);
122+
const result = await session.stringVariationDetail('my-flag', 'default');
123+
expect(result).toEqual(detail);
124+
expect(client.stringVariationDetail).toHaveBeenCalledWith('my-flag', context, 'default');
125+
});
126+
127+
it('jsonVariationDetail() calls base client with bound context', async () => {
128+
const client = makeMockBaseClient();
129+
const detail = {
130+
value: { key: 'value' },
131+
variationIndex: 1,
132+
reason: { kind: 'RULE_MATCH' as const },
133+
};
134+
// @ts-ignore — valid LDEvaluationDetailTyped<unknown> shape; mock type is too narrow
135+
client.jsonVariationDetail.mockResolvedValue(detail);
136+
const session = createLDServerSession(client, context);
137+
const result = await session.jsonVariationDetail('my-flag', {});
138+
expect(result).toEqual(detail);
139+
expect(client.jsonVariationDetail).toHaveBeenCalledWith('my-flag', context, {});
140+
});
141+
142+
it('allFlagsState() calls base client with bound context', async () => {
143+
const client = makeMockBaseClient();
144+
const session = createLDServerSession(client, context);
145+
await session.allFlagsState();
146+
expect(client.allFlagsState).toHaveBeenCalledWith(context, undefined);
147+
});
148+
149+
it('allFlagsState() forwards options to base client', async () => {
150+
const client = makeMockBaseClient();
151+
const session = createLDServerSession(client, context);
152+
const options = { clientSideOnly: true };
153+
await session.allFlagsState(options);
154+
expect(client.allFlagsState).toHaveBeenCalledWith(context, options);
155+
});
156+
157+
describe('given a browser environment (window defined)', () => {
158+
let originalWindow: typeof globalThis.window;
159+
160+
beforeEach(() => {
161+
originalWindow = globalThis.window;
162+
// @ts-ignore
163+
globalThis.window = {};
164+
});
165+
166+
afterEach(() => {
167+
// @ts-ignore
168+
globalThis.window = originalWindow;
169+
});
170+
171+
it('throws an error instead of returning a no-op session', () => {
172+
const client = makeMockBaseClient();
173+
expect(() => createLDServerSession(client, context)).toThrow(
174+
'createLDServerWrapper must only be called on the server.',
175+
);
176+
});
177+
});
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import { LDContext } from '@launchdarkly/js-server-sdk-common';
2+
3+
import { createLDServerSession, useLDServerSession } from '../../src/server/index';
4+
import type { LDServerSession } from '../../src/server/LDClient';
5+
6+
// The `mock` prefix is required so ts-jest's hoist plugin allows this variable
7+
// to be referenced inside the jest.mock() factory below.
8+
let mockCacheStore: { session: LDServerSession | null } = { session: null };
9+
10+
jest.mock('react', () => ({
11+
cache: (_fn: unknown) => () => mockCacheStore,
12+
}));
13+
14+
beforeEach(() => {
15+
mockCacheStore = { session: null };
16+
});
17+
18+
it('useLDServerSession() returns null when no session has been stored', () => {
19+
const result = useLDServerSession();
20+
expect(result).toBeNull();
21+
});
22+
23+
it('useLDServerSession() returns the session stored by createLDServerSession()', () => {
24+
const context: LDContext = { kind: 'user', key: 'test-user' };
25+
const client = {
26+
initialized: jest.fn(() => true),
27+
boolVariation: jest.fn(),
28+
numberVariation: jest.fn(),
29+
stringVariation: jest.fn(),
30+
jsonVariation: jest.fn(),
31+
boolVariationDetail: jest.fn(),
32+
numberVariationDetail: jest.fn(),
33+
stringVariationDetail: jest.fn(),
34+
jsonVariationDetail: jest.fn(),
35+
allFlagsState: jest.fn(),
36+
};
37+
// @ts-ignore — minimal mock satisfies LDServerBaseClient structurally
38+
const session = createLDServerSession(client, context);
39+
const result = useLDServerSession();
40+
expect(result).toBe(session);
41+
});
42+
43+
describe('given a browser environment (window defined)', () => {
44+
let originalWindow: typeof globalThis.window;
45+
46+
beforeEach(() => {
47+
originalWindow = globalThis.window;
48+
// @ts-ignore
49+
globalThis.window = {};
50+
});
51+
52+
afterEach(() => {
53+
// @ts-ignore
54+
globalThis.window = originalWindow;
55+
});
56+
57+
it('throws when called in a browser environment', () => {
58+
expect(() => useLDServerSession()).toThrow(
59+
'useLDServerSession must only be called on the server',
60+
);
61+
});
62+
});
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Temporary ignore to keep PRs manageable.
2+
3+
server-only

0 commit comments

Comments
 (0)