From 61658d1ddffcae5ef81dfc35334d93b42dba19de Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Thu, 12 Mar 2026 12:23:03 -0500 Subject: [PATCH 1/4] chore: refactoring react server component client --- packages/sdk/react/.eslintrc.cjs | 2 +- .../server/createLDServerSession.test.ts | 140 +++++++++++ packages/sdk/react/src/server/LDClient.ts | 225 +++--------------- .../sdk/react/src/server/LDContextProvider.ts | 38 --- packages/sdk/react/src/server/LDOptions.ts | 15 -- .../react/src/server/LDServerBaseClient.ts | 93 ++++++++ packages/sdk/react/src/server/index.ts | 194 +++++---------- packages/sdk/react/temp_docs/README.md | 2 +- .../temp_docs/creating-client-with-rsc.md | 32 +-- packages/sdk/react/temp_docs/example-usage.md | 44 ++-- packages/sdk/react/tsup.config.js | 60 +++-- 11 files changed, 384 insertions(+), 461 deletions(-) create mode 100644 packages/sdk/react/__tests__/server/createLDServerSession.test.ts delete mode 100644 packages/sdk/react/src/server/LDContextProvider.ts delete mode 100644 packages/sdk/react/src/server/LDOptions.ts create mode 100644 packages/sdk/react/src/server/LDServerBaseClient.ts diff --git a/packages/sdk/react/.eslintrc.cjs b/packages/sdk/react/.eslintrc.cjs index 57bd58ac4b..6b9a2ea78b 100644 --- a/packages/sdk/react/.eslintrc.cjs +++ b/packages/sdk/react/.eslintrc.cjs @@ -1,5 +1,5 @@ module.exports = { - ignorePatterns: ['contract-tests/next-env.d.ts'], + ignorePatterns: ['contract-tests/next-env.d.ts', 'examples/server-only/next-env.d.ts'], overrides: [ { files: ['contract-tests/**/*.ts', 'contract-tests/**/*.tsx'], diff --git a/packages/sdk/react/__tests__/server/createLDServerSession.test.ts b/packages/sdk/react/__tests__/server/createLDServerSession.test.ts new file mode 100644 index 0000000000..9cdac4dfb2 --- /dev/null +++ b/packages/sdk/react/__tests__/server/createLDServerSession.test.ts @@ -0,0 +1,140 @@ +import { LDContext, LDFlagsStateOptions } from '@launchdarkly/js-server-sdk-common'; + +import { createLDServerSession, isServer } from '../../src/server/index'; + +const context: LDContext = { kind: 'user', key: 'test-user' }; + +function makeMockBaseClient() { + return { + initialized: jest.fn(() => true), + boolVariation: jest.fn((_key: string, _ctx: LDContext, def: boolean) => Promise.resolve(def)), + numberVariation: jest.fn((_key: string, _ctx: LDContext, def: number) => Promise.resolve(def)), + stringVariation: jest.fn((_key: string, _ctx: LDContext, def: string) => Promise.resolve(def)), + jsonVariation: jest.fn((_key: string, _ctx: LDContext, def: unknown) => Promise.resolve(def)), + boolVariationDetail: jest.fn((_key: string, _ctx: LDContext, def: boolean) => + Promise.resolve({ value: def, variationIndex: null, reason: { kind: 'OFF' as const } }), + ), + numberVariationDetail: jest.fn((_key: string, _ctx: LDContext, def: number) => + Promise.resolve({ value: def, variationIndex: null, reason: { kind: 'OFF' as const } }), + ), + stringVariationDetail: jest.fn((_key: string, _ctx: LDContext, def: string) => + Promise.resolve({ value: def, variationIndex: null, reason: { kind: 'OFF' as const } }), + ), + jsonVariationDetail: jest.fn((_key: string, _ctx: LDContext, def: unknown) => + Promise.resolve({ value: def, variationIndex: null, reason: { kind: 'OFF' as const } }), + ), + // @ts-ignore — mock return shape matches LDFlagsState structurally + allFlagsState: jest.fn((_context: LDContext, _options?: LDFlagsStateOptions) => + Promise.resolve({ + valid: true, + getFlagValue: jest.fn(), + getFlagReason: jest.fn(), + allValues: jest.fn(() => ({})), + toJSON: jest.fn(() => ({ $flagsState: {}, $valid: true })), + }), + ), + }; +} + +it('isServer() returns true in a Node test environment', () => { + expect(isServer()).toBe(true); +}); + +it('getContext() returns the context passed at creation', () => { + const client = makeMockBaseClient(); + const session = createLDServerSession(client, context); + expect(session.getContext()).toEqual(context); +}); + +it('initialized() delegates to the base client', () => { + const client = makeMockBaseClient(); + client.initialized.mockReturnValue(false); + const session = createLDServerSession(client, context); + expect(session.initialized()).toBe(false); + expect(client.initialized).toHaveBeenCalledTimes(1); +}); + +it('boolVariation() calls base client with bound context', async () => { + const client = makeMockBaseClient(); + client.boolVariation.mockResolvedValue(true); + const session = createLDServerSession(client, context); + const result = await session.boolVariation('my-flag', false); + expect(result).toBe(true); + expect(client.boolVariation).toHaveBeenCalledWith('my-flag', context, false); +}); + +it('numberVariation() calls base client with bound context', async () => { + const client = makeMockBaseClient(); + client.numberVariation.mockResolvedValue(42); + const session = createLDServerSession(client, context); + const result = await session.numberVariation('my-flag', 0); + expect(result).toBe(42); + expect(client.numberVariation).toHaveBeenCalledWith('my-flag', context, 0); +}); + +it('stringVariation() calls base client with bound context', async () => { + const client = makeMockBaseClient(); + client.stringVariation.mockResolvedValue('hello'); + const session = createLDServerSession(client, context); + const result = await session.stringVariation('my-flag', 'default'); + expect(result).toBe('hello'); + expect(client.stringVariation).toHaveBeenCalledWith('my-flag', context, 'default'); +}); + +it('jsonVariation() calls base client with bound context', async () => { + const client = makeMockBaseClient(); + const json = { key: 'value' }; + client.jsonVariation.mockResolvedValue(json); + const session = createLDServerSession(client, context); + const result = await session.jsonVariation('my-flag', {}); + expect(result).toEqual(json); + expect(client.jsonVariation).toHaveBeenCalledWith('my-flag', context, {}); +}); + +it('boolVariationDetail() calls base client with bound context', async () => { + const client = makeMockBaseClient(); + const detail = { value: true, variationIndex: 1, reason: { kind: 'RULE_MATCH' as const } }; + // @ts-ignore — valid LDEvaluationDetailTyped shape; mock type is too narrow + client.boolVariationDetail.mockResolvedValue(detail); + const session = createLDServerSession(client, context); + const result = await session.boolVariationDetail('my-flag', false); + expect(result).toEqual(detail); + expect(client.boolVariationDetail).toHaveBeenCalledWith('my-flag', context, false); +}); + +it('allFlagsState() calls base client with bound context', async () => { + const client = makeMockBaseClient(); + const session = createLDServerSession(client, context); + await session.allFlagsState(); + expect(client.allFlagsState).toHaveBeenCalledWith(context, undefined); +}); + +it('allFlagsState() forwards options to base client', async () => { + const client = makeMockBaseClient(); + const session = createLDServerSession(client, context); + const options = { clientSideOnly: true }; + await session.allFlagsState(options); + expect(client.allFlagsState).toHaveBeenCalledWith(context, options); +}); + +describe('given a browser environment (window defined)', () => { + let originalWindow: typeof globalThis.window; + + beforeEach(() => { + originalWindow = globalThis.window; + // @ts-ignore + globalThis.window = {}; + }); + + afterEach(() => { + // @ts-ignore + globalThis.window = originalWindow; + }); + + it('throws an error instead of returning a no-op session', () => { + const client = makeMockBaseClient(); + expect(() => createLDServerSession(client, context)).toThrow( + 'createLDServerSession must only be called on the server.', + ); + }); +}); diff --git a/packages/sdk/react/src/server/LDClient.ts b/packages/sdk/react/src/server/LDClient.ts index 4fa9087b73..cad3efc11e 100644 --- a/packages/sdk/react/src/server/LDClient.ts +++ b/packages/sdk/react/src/server/LDClient.ts @@ -1,163 +1,60 @@ import { - LDEvaluationDetail, + LDContext, LDEvaluationDetailTyped, LDFlagsState, LDFlagsStateOptions, - LDFlagValue, } from '@launchdarkly/js-server-sdk-common'; /** - * The LaunchDarkly server client interface for React. + * A per-request evaluation scope that binds an {@link LDServerBaseClient} to a specific + * {@link LDContext}. * * @remarks - * This is a restrictive version of the LDClient interface. - * The main reason for this is to ensure we leverage client side - * rendering appropriately for more dynamic content. - * - * @privateRemarks - * We are basing this off the common server client interface so that we - * can potentially support edge sdk rendering. The main difference between this - * interface and the common server interface is that we do not have a context parameter. - * - * This is because the context is determined by the context provider and will be different - * for each request. This is also way that we can "scope" an existing LD server client to - * serve a specific request session. - * - * TODO: I also don't know if we really need the detail variations... not sure if they would - * really be useful for SSR. - * - * @see {@link LDReactServerOptions} for the possible options + * Unlike the server SDK (where every variation call requires a context parameter), + * `LDServerSession` binds the context at creation time. This is idiomatic for React + * Server Components, where the context comes from the incoming request (headers, cookies, + * auth tokens) and does not change during the render. * + * Create a session with {@link createLDServerSession}. */ -export interface LDReactServerClient { +export interface LDServerSession { /** - * Tests whether the client has completed initialization. - * - * If this returns false, it means that the client has not yet successfully connected to - * LaunchDarkly. It might still be in the process of starting up, or it might be attempting to - * reconnect after an unsuccessful attempt, or it might have received an unrecoverable error (such - * as an invalid SDK key) and given up. + * Tests whether the underlying server client has completed initialization. * - * @returns - * True if the client has successfully initialized. + * @returns True if the client has successfully initialized. */ initialized(): boolean; /** - * Determines the variation of a feature flag for a context. - * - * @param key The unique key of the feature flag. - * @param defaultValue The default value of the flag, to be used if the value is not available - * from LaunchDarkly. - * @param callback A Node-style callback to receive the result value. If omitted, you will receive - * a Promise instead. - * @returns - * If you provided a callback, then nothing. Otherwise, a Promise which will be resolved with - * the result value. + * Returns the context bound to this session. */ - variation( - key: string, - defaultValue: LDFlagValue, - callback?: (err: any, res: LDFlagValue) => void, - ): Promise; + getContext(): LDContext; /** - * Determines the variation of a feature flag for a context, along with information about how it - * was calculated. - * - * The `reason` property of the result will also be included in analytics events, if you are - * capturing detailed event data for this flag. - * - * For more information, see the [SDK reference - * guide](https://docs.launchdarkly.com/sdk/features/evaluation-reasons#nodejs-server-side). - * - * @param key The unique key of the feature flag. - * @param defaultValue The default value of the flag, to be used if the value is not available - * from LaunchDarkly. - * @param callback A Node-style callback to receive the result (as an {@link LDEvaluationDetail}). - * If omitted, you will receive a Promise instead. - * @returns - * If you provided a callback, then nothing. Otherwise, a Promise which will be resolved with - * the result (as an{@link LDEvaluationDetail}). - */ - variationDetail( - key: string, - defaultValue: LDFlagValue, - callback?: (err: any, res: LDEvaluationDetail) => void, - ): Promise; - - /** - * Determines the boolean variation of a feature flag for a context. - * - * If the flag variation does not have a boolean value, defaultValue is returned. - * - * @param key The unique key of the feature flag. - * @param defaultValue The default value of the flag, to be used if the value is not available - * from LaunchDarkly. - * @returns - * A Promise which will be resolved with the result value. + * Determines the boolean variation of a feature flag for this session's context. */ boolVariation(key: string, defaultValue: boolean): Promise; /** - * Determines the numeric variation of a feature flag for a context. - * - * If the flag variation does not have a numeric value, defaultValue is returned. - * - * @param key The unique key of the feature flag. - * @param defaultValue The default value of the flag, to be used if the value is not available - * from LaunchDarkly. - * @returns - * A Promise which will be resolved with the result value. + * Determines the numeric variation of a feature flag for this session's context. */ numberVariation(key: string, defaultValue: number): Promise; /** - * Determines the string variation of a feature flag for a context. - * - * If the flag variation does not have a string value, defaultValue is returned. - * - * @param key The unique key of the feature flag. - * @param defaultValue The default value of the flag, to be used if the value is not available - * from LaunchDarkly. - * @returns - * A Promise which will be resolved with the result value. + * Determines the string variation of a feature flag for this session's context. */ stringVariation(key: string, defaultValue: string): Promise; /** - * Determines the variation of a feature flag for a context. - * - * This version may be favored in TypeScript versus `variation` because it returns - * an `unknown` type instead of `any`. `unknown` will require a cast before usage. + * Determines the JSON variation of a feature flag for this session's context. * - * @param key The unique key of the feature flag. - * @param defaultValue The default value of the flag, to be used if the value is not available - * from LaunchDarkly. - * @returns - * A Promise which will be resolved with the result value. + * This version is preferred in TypeScript because it returns `unknown` instead of `any`, + * requiring an explicit cast before use. */ jsonVariation(key: string, defaultValue: unknown): Promise; /** - * Determines the boolean variation of a feature flag for a context, along with information about - * how it was calculated. - * - * The `reason` property of the result will also be included in analytics events, if you are - * capturing detailed event data for this flag. - * - * If the flag variation does not have a boolean value, defaultValue is returned. The reason will - * indicate an error of the type `WRONG_KIND` in this case. - * - * For more information, see the [SDK reference - * guide](https://docs.launchdarkly.com/sdk/features/evaluation-reasons#nodejs-server-side). - * - * @param key The unique key of the feature flag. - * @param defaultValue The default value of the flag, to be used if the value is not available - * from LaunchDarkly. - * @returns - * A Promise which will be resolved with the result - * (as an {@link LDEvaluationDetailTyped}). + * Determines the boolean variation of a feature flag, along with evaluation details. */ boolVariationDetail( key: string, @@ -165,24 +62,7 @@ export interface LDReactServerClient { ): Promise>; /** - * Determines the numeric variation of a feature flag for a context, along with information about - * how it was calculated. - * - * The `reason` property of the result will also be included in analytics events, if you are - * capturing detailed event data for this flag. - * - * If the flag variation does not have a numeric value, defaultValue is returned. The reason will - * indicate an error of the type `WRONG_KIND` in this case. - * - * For more information, see the [SDK reference - * guide](https://docs.launchdarkly.com/sdk/features/evaluation-reasons#nodejs-server-side). - * - * @param key The unique key of the feature flag. - * @param defaultValue The default value of the flag, to be used if the value is not available - * from LaunchDarkly. - * @returns - * A Promise which will be resolved with the result - * (as an {@link LDEvaluationDetailTyped}). + * Determines the numeric variation of a feature flag, along with evaluation details. */ numberVariationDetail( key: string, @@ -190,24 +70,7 @@ export interface LDReactServerClient { ): Promise>; /** - * Determines the string variation of a feature flag for a context, along with information about - * how it was calculated. - * - * The `reason` property of the result will also be included in analytics events, if you are - * capturing detailed event data for this flag. - * - * If the flag variation does not have a string value, defaultValue is returned. The reason will - * indicate an error of the type `WRONG_KIND` in this case. - * - * For more information, see the [SDK reference - * guide](https://docs.launchdarkly.com/sdk/features/evaluation-reasons#nodejs-server-side). - * - * @param key The unique key of the feature flag. - * @param defaultValue The default value of the flag, to be used if the value is not available - * from LaunchDarkly. - * @returns - * A Promise which will be resolved with the result - * (as an {@link LDEvaluationDetailTyped}). + * Determines the string variation of a feature flag, along with evaluation details. */ stringVariationDetail( key: string, @@ -215,26 +78,7 @@ export interface LDReactServerClient { ): Promise>; /** - * Determines the variation of a feature flag for a context, along with information about how it - * was calculated. - * - * The `reason` property of the result will also be included in analytics events, if you are - * capturing detailed event data for this flag. - * - * This version may be favored in TypeScript versus `variation` because it returns - * an `unknown` type instead of `any`. `unknown` will require a cast before usage. - * - * For more information, see the [SDK reference - * guide](https://docs.launchdarkly.com/sdk/features/evaluation-reasons#nodejs-server-side). - * - * @param key The unique key of the feature flag. - * @param defaultValue The default value of the flag, to be used if the value is not available - * from LaunchDarkly. - * @param callback A Node-style callback to receive the result (as an {@link LDEvaluationDetail}). - * If omitted, you will receive a Promise instead. - * @returns - * If you provided a callback, then nothing. Otherwise, a Promise which will be resolved with - * the result (as an{@link LDEvaluationDetailTyped}). + * Determines the JSON variation of a feature flag, along with evaluation details. */ jsonVariationDetail( key: string, @@ -242,25 +86,10 @@ export interface LDReactServerClient { ): Promise>; /** - * Builds an object that encapsulates the state of all feature flags for a given context. - * This includes the flag values and also metadata that can be used on the front end. This - * method does not send analytics events back to LaunchDarkly. - * - * The most common use case for this method is to bootstrap a set of client-side - * feature flags from a back-end service. Call the `toJSON()` method of the returned object - * to convert it to the data structure used by the client-side SDK. + * Builds an object encapsulating the state of all feature flags for this session's context. * - * @param options - * Optional {@link LDFlagsStateOptions} to determine how the state is computed. - * @param callback - * A Node-style callback to receive the result (as an {@link LDFlagsState}). If omitted, you - * will receive a Promise instead. - * @returns - * If you provided a callback, then nothing. Otherwise, a Promise which will be resolved - * with the result as an {@link LDFlagsState}. + * The most common use case is bootstrapping client-side flags from a back-end service. + * Call `toJSON()` on the returned object to get the data structure used by the client SDK. */ - allFlagsState( - options?: LDFlagsStateOptions, - callback?: (err: Error | null, res: LDFlagsState | null) => void, - ): Promise; + allFlagsState(options?: LDFlagsStateOptions): Promise; } diff --git a/packages/sdk/react/src/server/LDContextProvider.ts b/packages/sdk/react/src/server/LDContextProvider.ts deleted file mode 100644 index fc9c1bc963..0000000000 --- a/packages/sdk/react/src/server/LDContextProvider.ts +++ /dev/null @@ -1,38 +0,0 @@ -import { LDContext } from '@launchdarkly/js-server-sdk-common'; - -/** - * A provider for the LaunchDarkly context that can be used in - * the server components. - * - * @privateRemarks - * This interface is still under consideration and we will need to refine - * this when we start to implement some examples to see how it works in practice. - */ -export interface LDContextProvider { - /** - * getContext is used to determine the context that should be - * used for the request instance. This function will be called once - * per request. - * - * @remarks - * The reasons for this interface is that different frameworks may have - * different ways to determine the context from a request. - * - * @returns The LDContext for the request. - */ - getContext: () => LDContext; - - /** - * setContext is used to set the context that should be - * used for the request instance. This function will be called once - * per request. - * - * @remarks - * This is used to update the context that is associated with a - * browser session. This is optional and if not provided, then we will - * assume that the context is updated elsewhere. - * - * @param context The LDContext to set for the request. - */ - setContext?: (context: LDContext) => void; -} diff --git a/packages/sdk/react/src/server/LDOptions.ts b/packages/sdk/react/src/server/LDOptions.ts deleted file mode 100644 index 3c5a936d49..0000000000 --- a/packages/sdk/react/src/server/LDOptions.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { LDOptions as LDOptionsCommon } from '@launchdarkly/js-server-sdk-common'; - -import { LDContextProvider } from './LDContextProvider'; - -export interface LDReactServerOptions extends LDOptionsCommon { - /** - * A provider for the Launchdarkly context. - * - * @remarks - * This is left up to the developer to implement and will be used - * to do server side flag evalations. LDClient will not initialize - * if this is not provided. - */ - contextProvider: LDContextProvider; -} diff --git a/packages/sdk/react/src/server/LDServerBaseClient.ts b/packages/sdk/react/src/server/LDServerBaseClient.ts new file mode 100644 index 0000000000..882a91b46c --- /dev/null +++ b/packages/sdk/react/src/server/LDServerBaseClient.ts @@ -0,0 +1,93 @@ +import { + LDContext, + LDEvaluationDetailTyped, + LDFlagsState, + LDFlagsStateOptions, +} from '@launchdarkly/js-server-sdk-common'; + +/** + * A minimal structural interface that any LaunchDarkly server SDK that can be used with + * {@link createLDServerSession} should satisfy. + * + * @remarks + * This interface decouples the React SDK from the concrete `LDClient` type in + * `@launchdarkly/js-server-sdk-common`, allowing edge SDKs and other custom + * server client implementations to be used with {@link createLDServerSession}. + * For the most part, this interface should be compatible with any LaunchDarkly server SDK + * from the this monorepo. + */ +export interface LDServerBaseClient { + /** + * Tests whether the client has completed initialization. + * + * @returns True if the client has successfully initialized. + */ + initialized(): boolean; + + /** + * Determines the boolean variation of a feature flag for a context. + */ + boolVariation(key: string, context: LDContext, defaultValue: boolean): Promise; + + /** + * Determines the numeric variation of a feature flag for a context. + */ + numberVariation(key: string, context: LDContext, defaultValue: number): Promise; + + /** + * Determines the string variation of a feature flag for a context. + */ + stringVariation(key: string, context: LDContext, defaultValue: string): Promise; + + /** + * Determines the JSON variation of a feature flag for a context. + * + * This version is preferred in TypeScript because it returns `unknown` instead of `any`, + * requiring an explicit cast before use. + */ + jsonVariation(key: string, context: LDContext, defaultValue: unknown): Promise; + + /** + * Determines the boolean variation of a feature flag, along with evaluation details. + */ + boolVariationDetail( + key: string, + context: LDContext, + defaultValue: boolean, + ): Promise>; + + /** + * Determines the numeric variation of a feature flag, along with evaluation details. + */ + numberVariationDetail( + key: string, + context: LDContext, + defaultValue: number, + ): Promise>; + + /** + * Determines the string variation of a feature flag, along with evaluation details. + */ + stringVariationDetail( + key: string, + context: LDContext, + defaultValue: string, + ): Promise>; + + /** + * Determines the JSON variation of a feature flag, along with evaluation details. + */ + jsonVariationDetail( + key: string, + context: LDContext, + defaultValue: unknown, + ): Promise>; + + /** + * Builds an object encapsulating the state of all feature flags for a given context. + * + * The most common use case is bootstrapping client-side flags from a back-end service. + * Call `toJSON()` on the returned object to get the data structure used by the client SDK. + */ + allFlagsState(context: LDContext, options?: LDFlagsStateOptions): Promise; +} diff --git a/packages/sdk/react/src/server/index.ts b/packages/sdk/react/src/server/index.ts index 72855c853d..b2ecbeb482 100644 --- a/packages/sdk/react/src/server/index.ts +++ b/packages/sdk/react/src/server/index.ts @@ -1,37 +1,10 @@ -import { - LDClient, - type LDEvaluationDetail, - type LDEvaluationDetailTyped, - type LDFlagsState, - type LDFlagsStateOptions, -} from '@launchdarkly/js-server-sdk-common'; +import { LDContext, type LDFlagsStateOptions } from '@launchdarkly/js-server-sdk-common'; -import { LDReactServerClient } from './LDClient'; -import { LDReactServerOptions } from './LDOptions'; +import { LDServerSession } from './LDClient'; +import { LDServerBaseClient } from './LDServerBaseClient'; -export type * from './LDContextProvider'; -export type * from './LDOptions'; export type * from './LDClient'; - -const CLIENT_SIDE_REASON = { kind: 'ERROR' as const, errorKind: 'CLIENT_NOT_READY' }; - -function makeNoOpDetail(value: T): LDEvaluationDetail & { value: T } { - return { - value, - variationIndex: null, - reason: CLIENT_SIDE_REASON, - }; -} - -function makeNoOpFlagsState(): LDFlagsState { - return { - valid: false, - getFlagValue: () => null, - getFlagReason: () => null, - allValues: () => ({}), - toJSON: () => ({ $flagsState: {}, $valid: false }), - }; -} +export type * from './LDServerBaseClient'; /** * Returns true when code is running in a server environment (e.g. Node), false when running in the @@ -42,123 +15,64 @@ export function isServer(): boolean { } /** - * @returns A no-op client that returns default values and does not call the underlying LaunchDarkly client. - * This is useful when dealing with applications that are using React Server Components. + * Creates a per-request evaluation scope by binding an {@link LDServerBaseClient} to a specific + * context. * - * This fallback is helpful when compilers attempt to prerender components on build time. - * This will enable the components to at least be prerendered with their default values. - */ -function makeNoOpClient(): LDReactServerClient { - return { - variation: (key, defaultValue, callback) => { - const result = Promise.resolve(defaultValue); - if (callback) { - result.then((res) => callback(null, res)).catch((err) => callback(err, defaultValue)); - } - return result; - }, - variationDetail: (key, defaultValue, callback) => { - const detail = makeNoOpDetail(defaultValue); - const result = Promise.resolve(detail); - if (callback) { - result.then((res) => callback(null, res)).catch((err) => callback(err, detail)); - } - return result; - }, - boolVariation: (key, defaultValue) => Promise.resolve(defaultValue), - numberVariation: (key, defaultValue) => Promise.resolve(defaultValue), - stringVariation: (key, defaultValue) => Promise.resolve(defaultValue), - jsonVariation: (key, defaultValue) => Promise.resolve(defaultValue), - boolVariationDetail: (key, defaultValue) => - Promise.resolve(makeNoOpDetail(defaultValue) as LDEvaluationDetailTyped), - numberVariationDetail: (key, defaultValue) => - Promise.resolve(makeNoOpDetail(defaultValue) as LDEvaluationDetailTyped), - stringVariationDetail: (key, defaultValue) => - Promise.resolve(makeNoOpDetail(defaultValue) as LDEvaluationDetailTyped), - jsonVariationDetail: (key, defaultValue) => - Promise.resolve(makeNoOpDetail(defaultValue) as LDEvaluationDetailTyped), - initialized: () => false, - allFlagsState: (allFlagsStateOptions, callback) => { - const state = makeNoOpFlagsState(); - const result = Promise.resolve(state); - if (callback) { - result.then((res) => callback(null, res)).catch((err) => callback(err, null)); - } - return result; - }, - }; -} - -/** - * @experimental - * This function is experimental and may change in the future. + * @remarks + * Call this once per request (or share a session when the context is static, e.g. a shared + * anonymous context). The returned {@link LDServerSession} exposes the same variation API as the + * server SDK, but without the `context` parameter — the context is bound at creation time. The + * reason for this is purely idiomatic to align the server and client APIs closer together. + * + * @throws {Error} If called in a browser environment. This function must only be called on the + * server. Ensure the module that calls this is not imported from client components. + * + * @privateRemarks + * TODO: I think throwing an error might be better than just silently failing here. + * While the client -> server boundary is most likely a security loosening boundary, + * the server -> client boundary needs to be considered more carefully. Open to discussion. * - * Creates a restricted version of the common server client that is used for server side rendering. - * When not running on the server (e.g. in the browser), returns a no-op client that returns default - * values and does not call the underlying LaunchDarkly client. + * @example + * ```ts + * // lib/ld-server.ts + * import { init } from '@launchdarkly/node-server-sdk'; + * import { createLDServerSession } from '@launchdarkly/react-sdk/server'; * - * @param client The LaunchDarkly client. - * @param options The options for the React server client. - * @returns The React server client. The client is a restricted version of the common server client. + * const ldBaseClient = await init(process.env.LAUNCHDARKLY_SDK_KEY || ''); + * export const serverSession = createLDServerSession(ldBaseClient, defaultContext); + * ``` + * + * @param client Any LaunchDarkly server SDK client that satisfies {@link LDServerBaseClient}. + * @param context The context to bind to this session. Typically resolved from the request + * (e.g. from auth tokens, cookies, or headers). + * @returns An {@link LDServerSession} scoped to the given context. */ -export function createReactServerClient( - client: LDClient, - options: LDReactServerOptions, -): LDReactServerClient { +export function createLDServerSession( + client: LDServerBaseClient, + context: LDContext, +): LDServerSession { if (!isServer()) { - return makeNoOpClient(); - } - - if (!options.contextProvider) { - throw new Error('contextProvider is required'); + throw new Error( + 'createLDServerSession must only be called on the server. ' + + 'Ensure this module is not imported from client components.', + ); } - const { contextProvider } = options; return { - variation: (key, defaultValue, callback) => { - const context = contextProvider.getContext(); - return client.variation(key, context, defaultValue, callback); - }, - variationDetail: (key, defaultValue, callback) => { - const context = contextProvider.getContext(); - return client.variationDetail(key, context, defaultValue, callback); - }, - boolVariation: (key, defaultValue) => { - const context = contextProvider.getContext(); - return client.boolVariation(key, context, defaultValue); - }, - numberVariation: (key, defaultValue) => { - const context = contextProvider.getContext(); - return client.numberVariation(key, context, defaultValue); - }, - stringVariation: (key, defaultValue) => { - const context = contextProvider.getContext(); - return client.stringVariation(key, context, defaultValue); - }, - jsonVariation: (key, defaultValue) => { - const context = contextProvider.getContext(); - return client.jsonVariation(key, context, defaultValue); - }, - boolVariationDetail: (key, defaultValue) => { - const context = contextProvider.getContext(); - return client.boolVariationDetail(key, context, defaultValue); - }, - numberVariationDetail: (key, defaultValue) => { - const context = contextProvider.getContext(); - return client.numberVariationDetail(key, context, defaultValue); - }, - stringVariationDetail: (key, defaultValue) => { - const context = contextProvider.getContext(); - return client.stringVariationDetail(key, context, defaultValue); - }, - jsonVariationDetail: (key, defaultValue) => { - const context = contextProvider.getContext(); - return client.jsonVariationDetail(key, context, defaultValue); - }, initialized: () => client.initialized(), - allFlagsState: (allFlagsStateOptions: LDFlagsStateOptions, callback) => { - const context = contextProvider.getContext(); - return client.allFlagsState(context, allFlagsStateOptions, callback); - }, + getContext: () => context, + boolVariation: (key, defaultValue) => client.boolVariation(key, context, defaultValue), + numberVariation: (key, defaultValue) => client.numberVariation(key, context, defaultValue), + stringVariation: (key, defaultValue) => client.stringVariation(key, context, defaultValue), + jsonVariation: (key, defaultValue) => client.jsonVariation(key, context, defaultValue), + boolVariationDetail: (key, defaultValue) => + client.boolVariationDetail(key, context, defaultValue), + numberVariationDetail: (key, defaultValue) => + client.numberVariationDetail(key, context, defaultValue), + stringVariationDetail: (key, defaultValue) => + client.stringVariationDetail(key, context, defaultValue), + jsonVariationDetail: (key, defaultValue) => + client.jsonVariationDetail(key, context, defaultValue), + allFlagsState: (options?: LDFlagsStateOptions) => client.allFlagsState(context, options), }; } diff --git a/packages/sdk/react/temp_docs/README.md b/packages/sdk/react/temp_docs/README.md index 5b1d6034cc..98e6c4ef88 100644 --- a/packages/sdk/react/temp_docs/README.md +++ b/packages/sdk/react/temp_docs/README.md @@ -4,7 +4,7 @@ This documentation directory will exist until this SDK is GA. - [Setting up the LaunchDarkly client context (client-only)](client-context-setup.md) – Creating the client and React context with `createClient`, `initLDReactContext`, and `createLDReactProvider`. -- [Creating a client with RSC](creating-client-with-rsc.md) – Using the browser client and server client with React Server Components. +- [Creating a client with RSC](creating-client-with-rsc.md) – Using the browser client and server session with React Server Components. - [Example usage](example-usage.md) – Sample code snippets for client-only, server-only, and server-and-client examples; references all apps in `examples/`. For official documentation, please see https://launchdarkly.com/docs/home \ No newline at end of file diff --git a/packages/sdk/react/temp_docs/creating-client-with-rsc.md b/packages/sdk/react/temp_docs/creating-client-with-rsc.md index 13db364add..831853fc17 100644 --- a/packages/sdk/react/temp_docs/creating-client-with-rsc.md +++ b/packages/sdk/react/temp_docs/creating-client-with-rsc.md @@ -8,27 +8,27 @@ To use LaunchDarkly in an app that uses React Server Components (RSC), the **cur 1. **Create the browser client** with `createClient` from `@launchdarkly/react-sdk` in a **shared** module (e.g. `ld-client.ts`) that does not import any server-only code. That same module can run on server and client; on the server, `createClient` returns a noop client, so the browser SDK is never loaded in server bundles. -2. **Create the server client** in **server-only** code (e.g. `ld-server.ts`) with your LaunchDarkly server SDK and `createReactServerClient` from `@launchdarkly/react-sdk/server`, providing a `contextProvider` so the server client has the LD context for each request. +2. **Create the server session** in **server-only** code (e.g. `ld-server.ts`) with your LaunchDarkly server SDK and `createLDServerSession` from `@launchdarkly/react-sdk/server`, passing the request context directly. -3. **Use the right client per boundary:** In Server Components, import the server client from `ld-server` and use `await ldServer.variation(key, default)`. In Client Components, import the browser client from `ld-client` and use `ldClient.variation()`, `ldClient.on()`, `ldClient.waitForInitialization()`, etc. +3. **Use the right client per boundary:** In Server Components, import the server session from `ld-server` and use `await ldServer.boolVariation(key, default)` (or the appropriate typed method). In Client Components, import the browser client from `ld-client` and use `ldClient.variation()`, `ldClient.on()`, `ldClient.waitForInitialization()`, etc. ## Entry points and types - **Main entry (`@launchdarkly/react-sdk`):** `createClient(clientSideID, context, options?)` returns an `LDReactClient`. On the server it is a noop; in the browser it is the real client. Options are `LDReactClientOptions` (e.g. streaming, bootstrap). The main entry uses `'client-only'` so it is not bundled for the server. -- **Server entry (`@launchdarkly/react-sdk/server`):** `createReactServerClient(serverClient, options)` accepts a standard LaunchDarkly server `LDClient` (e.g. from the Node SDK) and `LDReactServerOptions` (which requires `contextProvider: LDContextProvider`). Returns an `LDReactServerClient`. The server entry uses `'server-only'`. When run in the browser (e.g. in code that is shared), `createReactServerClient` returns a no-op client that returns default values. +- **Server entry (`@launchdarkly/react-sdk/server`):** `createLDServerSession(client, context)` accepts any `LDServerBaseClient` (the minimal structural type satisfied by any LD server SDK, e.g. from the Node SDK) and an `LDContext`. Returns an `LDServerSession` with typed evaluation methods (`boolVariation`, `numberVariation`, `stringVariation`, `jsonVariation`). The server entry uses `'server-only'`. ## Intended flow (step-by-step) 1. **Create the browser client in a shared module.** In a file that is safe to import from both Server and Client Components (e.g. `app/lib/ld-client.ts`), call `createClient(clientSideID, context, options)` from `@launchdarkly/react-sdk`. Export the client (e.g. default export `ldClient`). Do not import `ld-server` or `@launchdarkly/react-sdk/server` here. On the server this client is a noop; in the browser it is the real client. You can call `ldClient.start()` in this module so the client starts when used in the browser. -2. **Create the server client in server-only code.** In a file that is only ever imported by Server Components or other server-only modules (e.g. `app/lib/ld-server.ts`), create your LaunchDarkly server client (e.g. with `init()` from `@launchdarkly/node-server-sdk`). Implement a `contextProvider` whose `getContext()` returns the LaunchDarkly context for the current request (e.g. from session, headers, or cookies). Call `createReactServerClient(serverClient, { contextProvider })` from `@launchdarkly/react-sdk/server` and export the result (e.g. default export `serverClient` or `ldServer`). +2. **Create the server session in server-only code.** In a file that is only ever imported by Server Components or other server-only modules (e.g. `app/lib/ld-server.ts`), create your LaunchDarkly server client (e.g. with `init()` from `@launchdarkly/node-server-sdk`). Obtain the `LDContext` for the current request (e.g. from session, headers, or cookies). Call `createLDServerSession(serverClient, context)` from `@launchdarkly/react-sdk/server` and export the result (e.g. default export `ldServer`). -3. **Use the client.** In **Server Components**, import the server client from the server-only module (e.g. `ldServer` from `./lib/ld-server`) and call `await ldServer.variation(flagKey, defaultValue)`. In **Client Components**, import the browser client from the shared module (e.g. `ldClient` from `./lib/ld-client`) and use `ldClient.variation()`, `ldClient.on()`, `ldClient.waitForInitialization()`, etc., as needed. +3. **Use the client.** In **Server Components**, import the server session from the server-only module (e.g. `ldServer` from `./lib/ld-server`) and call `await ldServer.boolVariation(flagKey, defaultValue)` (or `numberVariation`, `stringVariation`, `jsonVariation` as appropriate). In **Client Components**, import the browser client from the shared module (e.g. `ldClient` from `./lib/ld-client`) and use `ldClient.variation()`, `ldClient.on()`, `ldClient.waitForInitialization()`, etc., as needed. -## Context provider +## Context -`LDContextProvider` is the bridge between your framework (e.g. Next.js App Router) and LaunchDarkly on the server. It has a required `getContext()` that returns the `LDContext` for the current request. Optionally, `setContext(context)` can be used to update the context. Implementation is application-specific: you might read the user from session, headers, or cookies and build an `LDContext` from that. +The `LDContext` is passed directly to `createLDServerSession(client, context)` at session creation time. Build your context from whatever source is appropriate for the current request — headers, cookies, session data, etc. Each request should create a session with the context for that request. ## Code examples @@ -61,18 +61,12 @@ This module must only be imported by Server Components or other server-only code ```ts import { init } from '@launchdarkly/node-server-sdk'; -import { createReactServerClient } from '@launchdarkly/react-sdk/server'; +import { createLDServerSession } from '@launchdarkly/react-sdk/server'; import { defaultContext } from './ld-context'; -const ldClient = init(process.env.LAUNCHDARKLY_SDK_KEY || ''); - -const serverClient = createReactServerClient(ldClient, { - contextProvider: { - getContext: () => defaultContext, - }, -}); - -export default serverClient; +const ldBaseClient = init(process.env.LAUNCHDARKLY_SDK_KEY || ''); +const ldServer = createLDServerSession(ldBaseClient, defaultContext); +export default ldServer; ``` **Server Component (e.g. `app/page.tsx`)** @@ -84,7 +78,7 @@ import ClientRendered from './client-rendered'; const flagKey = 'sample-feature'; export default async function Home() { - const serverFlagValue = await ldServer.variation(flagKey, false); + const serverFlagValue = await ldServer.boolVariation(flagKey, false); return ( <>

Server: {flagKey} is {serverFlagValue ? 'on' : 'off'}

@@ -132,4 +126,4 @@ export default function ClientRendered() { ## Entry points and tree-shaking -The main entry (`@launchdarkly/react-sdk`) is built with `'client-only'`, so it is not bundled for the server. The server entry (`@launchdarkly/react-sdk/server`) is built with `'server-only'`. If you only use the main entry in client bundles, server code is not included. When `createReactServerClient` is called in the browser (e.g. in shared code), it returns a no-op client, so it is safe to use from modules that might load on both sides—but for clarity, prefer importing `ld-server` only in server code so Client Components never pull in the server module. +The main entry (`@launchdarkly/react-sdk`) is built with `'client-only'`, so it is not bundled for the server. The server entry (`@launchdarkly/react-sdk/server`) is built with `'server-only'`. If you only use the main entry in client bundles, server code is not included. Prefer importing `ld-server` only in server code so Client Components never pull in the server module. diff --git a/packages/sdk/react/temp_docs/example-usage.md b/packages/sdk/react/temp_docs/example-usage.md index 1a9d272f36..cccd7b25ca 100644 --- a/packages/sdk/react/temp_docs/example-usage.md +++ b/packages/sdk/react/temp_docs/example-usage.md @@ -7,7 +7,7 @@ This doc will examine 3 example uses of the launchdarkly `react-sdk`: | Example | Description | |--------|-------------| | **client-only** | Browser-only React app (e.g. Create React App). Single client created with `createClient` and provided via React context. | -| **server-only** | Next.js App Router with Server Components only. LaunchDarkly Node SDK + `createReactServerClient` for flag evaluation on the server. | +| **server-only** | Next.js App Router with Server Components only. LaunchDarkly Node SDK + `createLDServerSession` for flag evaluation on the server. | | **server-and-client** | Next.js App Router with both Server and Client Components. Shared browser client + server client, with clear import boundaries. | --- @@ -97,18 +97,18 @@ function App() { ## 2. Server-only app (Next.js RSC) -Use when you only need flag evaluation in Server Components. No browser client; the Node SDK plus `createReactServerClient` provide a request-scoped client that uses a `contextProvider` for the current user. +Use when you only need flag evaluation in Server Components. No browser client; the Node SDK plus `createLDServerSession` provide a request-scoped session bound to a context. -### Create the server client +### Create the server session -Create the LaunchDarkly Node client and wrap it with `createReactServerClient`, providing a `contextProvider` that returns the LD context for the current request (e.g. from session or headers). +Create the LaunchDarkly Node client and call `createLDServerSession(ldClient, context)`, passing the context for the current request directly. ```ts // /app/page.tsx (conceptually; in the example everything lives in page.tsx) import { init } from '@launchdarkly/node-server-sdk'; -import { createReactServerClient } from '@launchdarkly/react-sdk/server'; +import { createLDServerSession } from '@launchdarkly/react-sdk/server'; -const ldClient = init(process.env.LAUNCHDARKLY_SDK_KEY || ''); +const ldBaseClient = init(process.env.LAUNCHDARKLY_SDK_KEY || ''); const context = { kind: 'user', @@ -116,22 +116,18 @@ const context = { name: 'Sandy', }; -const serverClient = createReactServerClient(ldClient, { - contextProvider: { - getContext: () => context, - }, -}); +const serverSession = createLDServerSession(ldBaseClient, context); ``` ### Use in a Server Component -Import the server client only in Server Components (or server-only modules). Call `await serverClient.variation(flagKey, defaultValue)`. +Import the server session only in Server Components (or server-only modules). Call `await serverSession.boolVariation(flagKey, defaultValue)`. ```tsx // /app/page.tsx export default async function Home() { - await ldClient.waitForInitialization({ timeout: 10 }); - const featureFlag = await serverClient.variation(flagKey, false); + await ldBaseClient.waitForInitialization({ timeout: 10 }); + const featureFlag = await serverSession.boolVariation(flagKey, false); return <>{featureFlag ? 'Hello world' : 'Hello world disabled'}; } @@ -183,22 +179,18 @@ Create the server client in a **server-only** module. Never import this file fro ```ts // /app/lib/ld-server.ts import { init } from '@launchdarkly/node-server-sdk'; -import { createReactServerClient } from '@launchdarkly/react-sdk/server'; +import { createLDServerSession } from '@launchdarkly/react-sdk/server'; import { defaultContext } from './ld-context'; -const ldClient = init(process.env.LAUNCHDARKLY_SDK_KEY || ''); -const serverClient = createReactServerClient(ldClient, { - contextProvider: { - getContext: () => defaultContext, - }, -}); +const ldBaseClient = init(process.env.LAUNCHDARKLY_SDK_KEY || ''); +const ldServer = createLDServerSession(ldBaseClient, defaultContext); -export default serverClient; +export default ldServer; ``` ### Server Component: evaluate flags at request time -Import `ld-server` only in Server Components. Use `await ldServer.variation(key, default)`. +Import `ld-server` only in Server Components. Use `await ldServer.boolVariation(key, default)`. ```tsx // /app/page.tsx @@ -210,7 +202,7 @@ import ServerContent from './server-content'; const FLAG_KEY = 'sample-feature'; export default async function Home() { - const flagValue = await ldServer.variation(FLAG_KEY, false); + const flagValue = await ldServer.boolVariation(FLAG_KEY, false); return (
@@ -234,7 +226,7 @@ import ldServer from './lib/ld-server'; import ClientIsland from './client-island'; export default async function ServerSection() { - const flagValue = await ldServer.variation(FLAG_KEY, false); + const flagValue = await ldServer.boolVariation(FLAG_KEY, false); return ( <> @@ -254,7 +246,7 @@ You can pass a Server Component as `children` to a Client Component. The server import ldServer from './lib/ld-server'; export default async function ServerContent() { - const flagValue = await ldServer.variation(FLAG_KEY, false); + const flagValue = await ldServer.boolVariation(FLAG_KEY, false); return ; } ``` diff --git a/packages/sdk/react/tsup.config.js b/packages/sdk/react/tsup.config.js index dd147d1bef..0b56491234 100644 --- a/packages/sdk/react/tsup.config.js +++ b/packages/sdk/react/tsup.config.js @@ -1,32 +1,46 @@ import { defineConfig } from 'tsup'; +const sharedOptions = { + minify: true, + format: ['esm', 'cjs'], + splitting: false, + sourcemap: false, + noExternal: [ + '@launchdarkly/js-sdk-common', + '@launchdarkly/js-client-sdk-common', + '@launchdarkly/js-client-sdk', + '@launchdarkly/js-server-sdk', + ], + dts: true, + metafile: true, +}; + +const mangleProps = (opts) => { + // This would normally be `^_(?!meta|_)`, but go doesn't support negative look-ahead assertions, + // so we need to craft something that works without it. + // So start of line followed by a character that isn't followed by m or underscore, but we + // want other things that do start with m, so we need to progressively handle more characters + // of meta with exclusions. + // eslint-disable-next-line no-param-reassign + opts.mangleProps = /^_([^m|_]|m[^e]|me[^t]|met[^a])/; +}; + export default defineConfig([ { - entry: { - index: 'src/client/index.ts', - server: 'src/server/index.ts', - }, - minify: true, - format: ['esm', 'cjs'], - splitting: false, - sourcemap: false, + ...sharedOptions, + entry: { index: 'src/client/index.ts' }, clean: true, - noExternal: [ - '@launchdarkly/js-sdk-common', - '@launchdarkly/js-client-sdk-common', - '@launchdarkly/js-client-sdk', - '@launchdarkly/js-server-sdk', - ], - dts: true, - metafile: true, esbuildOptions(opts) { - // This would normally be `^_(?!meta|_)`, but go doesn't support negative look-ahead assertions, - // so we need to craft something that works without it. - // So start of line followed by a character that isn't followed by m or underscore, but we - // want other things that do start with m, so we need to progressively handle more characters - // of meta with exclusions. - // eslint-disable-next-line no-param-reassign - opts.mangleProps = /^_([^m|_]|m[^e]|me[^t]|met[^a])/; + opts.banner = { js: '"use client";' }; + mangleProps(opts); + }, + }, + { + ...sharedOptions, + entry: { server: 'src/server/index.ts' }, + clean: false, + esbuildOptions(opts) { + mangleProps(opts); }, }, ]); From f2eee29312881b51fd0f0b631e2413da0e922e91 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Fri, 13 Mar 2026 11:14:36 -0500 Subject: [PATCH 2/4] chore: now using react `cache` for managing server sessions --- .../server/createLDServerSession.test.ts | 37 +++++ .../server/useLDServerSession.test.ts | 62 +++++++++ .../sdk/react/src/server/LDServerSession.ts | 109 +++++++++++++++ packages/sdk/react/src/server/index.ts | 77 +---------- .../temp_docs/creating-client-with-rsc.md | 129 ------------------ packages/sdk/react/temp_docs/example-usage.md | 102 +++++++++----- 6 files changed, 279 insertions(+), 237 deletions(-) create mode 100644 packages/sdk/react/__tests__/server/useLDServerSession.test.ts create mode 100644 packages/sdk/react/src/server/LDServerSession.ts delete mode 100644 packages/sdk/react/temp_docs/creating-client-with-rsc.md diff --git a/packages/sdk/react/__tests__/server/createLDServerSession.test.ts b/packages/sdk/react/__tests__/server/createLDServerSession.test.ts index 9cdac4dfb2..3d392f7d10 100644 --- a/packages/sdk/react/__tests__/server/createLDServerSession.test.ts +++ b/packages/sdk/react/__tests__/server/createLDServerSession.test.ts @@ -102,6 +102,43 @@ it('boolVariationDetail() calls base client with bound context', async () => { expect(client.boolVariationDetail).toHaveBeenCalledWith('my-flag', context, false); }); +it('numberVariationDetail() calls base client with bound context', async () => { + const client = makeMockBaseClient(); + const detail = { value: 42, variationIndex: 1, reason: { kind: 'RULE_MATCH' as const } }; + // @ts-ignore — valid LDEvaluationDetailTyped shape; mock type is too narrow + client.numberVariationDetail.mockResolvedValue(detail); + const session = createLDServerSession(client, context); + const result = await session.numberVariationDetail('my-flag', 0); + expect(result).toEqual(detail); + expect(client.numberVariationDetail).toHaveBeenCalledWith('my-flag', context, 0); +}); + +it('stringVariationDetail() calls base client with bound context', async () => { + const client = makeMockBaseClient(); + const detail = { value: 'hello', variationIndex: 1, reason: { kind: 'RULE_MATCH' as const } }; + // @ts-ignore — valid LDEvaluationDetailTyped shape; mock type is too narrow + client.stringVariationDetail.mockResolvedValue(detail); + const session = createLDServerSession(client, context); + const result = await session.stringVariationDetail('my-flag', 'default'); + expect(result).toEqual(detail); + expect(client.stringVariationDetail).toHaveBeenCalledWith('my-flag', context, 'default'); +}); + +it('jsonVariationDetail() calls base client with bound context', async () => { + const client = makeMockBaseClient(); + const detail = { + value: { key: 'value' }, + variationIndex: 1, + reason: { kind: 'RULE_MATCH' as const }, + }; + // @ts-ignore — valid LDEvaluationDetailTyped shape; mock type is too narrow + client.jsonVariationDetail.mockResolvedValue(detail); + const session = createLDServerSession(client, context); + const result = await session.jsonVariationDetail('my-flag', {}); + expect(result).toEqual(detail); + expect(client.jsonVariationDetail).toHaveBeenCalledWith('my-flag', context, {}); +}); + it('allFlagsState() calls base client with bound context', async () => { const client = makeMockBaseClient(); const session = createLDServerSession(client, context); diff --git a/packages/sdk/react/__tests__/server/useLDServerSession.test.ts b/packages/sdk/react/__tests__/server/useLDServerSession.test.ts new file mode 100644 index 0000000000..93d75157e2 --- /dev/null +++ b/packages/sdk/react/__tests__/server/useLDServerSession.test.ts @@ -0,0 +1,62 @@ +import { LDContext } from '@launchdarkly/js-server-sdk-common'; + +import { createLDServerSession, useLDServerSession } from '../../src/server/index'; +import type { LDServerSession } from '../../src/server/LDClient'; + +// The `mock` prefix is required so ts-jest's hoist plugin allows this variable +// to be referenced inside the jest.mock() factory below. +let mockCacheStore: { session: LDServerSession | null } = { session: null }; + +jest.mock('react', () => ({ + cache: (_fn: unknown) => () => mockCacheStore, +})); + +beforeEach(() => { + mockCacheStore = { session: null }; +}); + +it('useLDServerSession() returns null when no session has been stored', () => { + const result = useLDServerSession(); + expect(result).toBeNull(); +}); + +it('useLDServerSession() returns the session stored by createLDServerSession()', () => { + const context: LDContext = { kind: 'user', key: 'test-user' }; + const client = { + initialized: jest.fn(() => true), + 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(), + }; + // @ts-ignore — minimal mock satisfies LDServerBaseClient structurally + const session = createLDServerSession(client, context); + const result = useLDServerSession(); + expect(result).toBe(session); +}); + +describe('given a browser environment (window defined)', () => { + let originalWindow: typeof globalThis.window; + + beforeEach(() => { + originalWindow = globalThis.window; + // @ts-ignore + globalThis.window = {}; + }); + + afterEach(() => { + // @ts-ignore + globalThis.window = originalWindow; + }); + + it('throws when called in a browser environment', () => { + expect(() => useLDServerSession()).toThrow( + 'useLDServerSession must only be called on the server', + ); + }); +}); diff --git a/packages/sdk/react/src/server/LDServerSession.ts b/packages/sdk/react/src/server/LDServerSession.ts new file mode 100644 index 0000000000..512ca2c5cd --- /dev/null +++ b/packages/sdk/react/src/server/LDServerSession.ts @@ -0,0 +1,109 @@ +import { cache } from 'react'; + +import { LDContext, type LDFlagsStateOptions } from '@launchdarkly/js-server-sdk-common'; + +import { LDServerSession } from './LDClient'; +import { LDServerBaseClient } from './LDServerBaseClient'; + +// cache() creates a per-request memoized store — each React render tree (request) +// gets its own isolated instance. The store is populated by LDIsomorphicProvider +// and read by serverBoolVariation / useBoolVariation. +const withCache = cache(() => ({ session: null as LDServerSession | null })); + +/** + * Boundary check for server only code. Given that we are assuming a React ecosystem, + * simply checking for the presence of window should be sufficient. + */ +export function isServer(): boolean { + return typeof window === 'undefined'; +} + +/** + * Creates a per-request evaluation scope by binding an {@link LDServerBaseClient} to a specific + * context. + * + * @remarks + * Call this once per request (or share a session when the context is static, e.g. a shared + * anonymous context). The returned {@link LDServerSession} exposes the same variation API as the + * server SDK, but without the `context` parameter — the context is bound at creation time. The + * reason for this is purely idiomatic to align the server and client APIs closer together. + * + * @throws {Error} If called in a browser environment. This function must only be called on the + * server. Ensure the module that calls this is not imported from client components. + * + * @privateRemarks + * TODO: I think throwing an error might be better than just silently failing here. + * While the client -> server boundary is most likely a security loosening boundary, + * the server -> client boundary needs to be considered more carefully. Open to discussion. + * + * @example + * ```ts + * // lib/ld-server.ts + * import { init } from '@launchdarkly/node-server-sdk'; + * import { createLDServerSession } from '@launchdarkly/react-sdk/server'; + * + * const ldBaseClient = await init(process.env.LAUNCHDARKLY_SDK_KEY || ''); + * export const serverSession = createLDServerSession(ldBaseClient, defaultContext); + * ``` + * + * @param client Any LaunchDarkly server SDK client that satisfies {@link LDServerBaseClient}. + * @param context The context to bind to this session. Typically resolved from the request + * (e.g. from auth tokens, cookies, or headers). + * @returns An {@link LDServerSession} scoped to the given context. + */ +export function createLDServerWrapper( + client: LDServerBaseClient, + context: LDContext, +): LDServerSession { + if (!isServer()) { + throw new Error( + 'createLDServerSession must only be called on the server. ' + + 'Ensure this module is not imported from client components.', + ); + } + + return { + initialized: () => client.initialized(), + getContext: () => context, + boolVariation: (key, defaultValue) => client.boolVariation(key, context, defaultValue), + numberVariation: (key, defaultValue) => client.numberVariation(key, context, defaultValue), + stringVariation: (key, defaultValue) => client.stringVariation(key, context, defaultValue), + jsonVariation: (key, defaultValue) => client.jsonVariation(key, context, defaultValue), + boolVariationDetail: (key, defaultValue) => + client.boolVariationDetail(key, context, defaultValue), + numberVariationDetail: (key, defaultValue) => + client.numberVariationDetail(key, context, defaultValue), + stringVariationDetail: (key, defaultValue) => + client.stringVariationDetail(key, context, defaultValue), + jsonVariationDetail: (key, defaultValue) => + client.jsonVariationDetail(key, context, defaultValue), + allFlagsState: (options?: LDFlagsStateOptions) => client.allFlagsState(context, options), + }; +} + +export function createLDServerSession( + client: LDServerBaseClient, + context: LDContext, +): LDServerSession { + const session = createLDServerWrapper(client, context); + + withCache().session = session; + + return session; +} + +export function useLDServerSession(): LDServerSession | null { + if (!isServer()) { + throw new Error( + 'useLDServerSession must only be called on the server. ' + + 'Ensure this module is not imported from client components.', + ); + } + + const { session } = withCache(); + if (!session) { + return null; + } + + return session; +} diff --git a/packages/sdk/react/src/server/index.ts b/packages/sdk/react/src/server/index.ts index b2ecbeb482..db59369572 100644 --- a/packages/sdk/react/src/server/index.ts +++ b/packages/sdk/react/src/server/index.ts @@ -1,78 +1,3 @@ -import { LDContext, type LDFlagsStateOptions } from '@launchdarkly/js-server-sdk-common'; - -import { LDServerSession } from './LDClient'; -import { LDServerBaseClient } from './LDServerBaseClient'; - export type * from './LDClient'; export type * from './LDServerBaseClient'; - -/** - * Returns true when code is running in a server environment (e.g. Node), false when running in the - * browser or other client environment. - */ -export function isServer(): boolean { - return typeof window === 'undefined'; -} - -/** - * Creates a per-request evaluation scope by binding an {@link LDServerBaseClient} to a specific - * context. - * - * @remarks - * Call this once per request (or share a session when the context is static, e.g. a shared - * anonymous context). The returned {@link LDServerSession} exposes the same variation API as the - * server SDK, but without the `context` parameter — the context is bound at creation time. The - * reason for this is purely idiomatic to align the server and client APIs closer together. - * - * @throws {Error} If called in a browser environment. This function must only be called on the - * server. Ensure the module that calls this is not imported from client components. - * - * @privateRemarks - * TODO: I think throwing an error might be better than just silently failing here. - * While the client -> server boundary is most likely a security loosening boundary, - * the server -> client boundary needs to be considered more carefully. Open to discussion. - * - * @example - * ```ts - * // lib/ld-server.ts - * import { init } from '@launchdarkly/node-server-sdk'; - * import { createLDServerSession } from '@launchdarkly/react-sdk/server'; - * - * const ldBaseClient = await init(process.env.LAUNCHDARKLY_SDK_KEY || ''); - * export const serverSession = createLDServerSession(ldBaseClient, defaultContext); - * ``` - * - * @param client Any LaunchDarkly server SDK client that satisfies {@link LDServerBaseClient}. - * @param context The context to bind to this session. Typically resolved from the request - * (e.g. from auth tokens, cookies, or headers). - * @returns An {@link LDServerSession} scoped to the given context. - */ -export function createLDServerSession( - client: LDServerBaseClient, - context: LDContext, -): LDServerSession { - if (!isServer()) { - throw new Error( - 'createLDServerSession must only be called on the server. ' + - 'Ensure this module is not imported from client components.', - ); - } - - return { - initialized: () => client.initialized(), - getContext: () => context, - boolVariation: (key, defaultValue) => client.boolVariation(key, context, defaultValue), - numberVariation: (key, defaultValue) => client.numberVariation(key, context, defaultValue), - stringVariation: (key, defaultValue) => client.stringVariation(key, context, defaultValue), - jsonVariation: (key, defaultValue) => client.jsonVariation(key, context, defaultValue), - boolVariationDetail: (key, defaultValue) => - client.boolVariationDetail(key, context, defaultValue), - numberVariationDetail: (key, defaultValue) => - client.numberVariationDetail(key, context, defaultValue), - stringVariationDetail: (key, defaultValue) => - client.stringVariationDetail(key, context, defaultValue), - jsonVariationDetail: (key, defaultValue) => - client.jsonVariationDetail(key, context, defaultValue), - allFlagsState: (options?: LDFlagsStateOptions) => client.allFlagsState(context, options), - }; -} +export * from './LDServerSession'; diff --git a/packages/sdk/react/temp_docs/creating-client-with-rsc.md b/packages/sdk/react/temp_docs/creating-client-with-rsc.md deleted file mode 100644 index 831853fc17..0000000000 --- a/packages/sdk/react/temp_docs/creating-client-with-rsc.md +++ /dev/null @@ -1,129 +0,0 @@ -# Creating a LaunchDarkly client with React Server Components - -> **Status:** The LaunchDarkly React SDK and RSC support are experimental. The APIs described in this document may change. - -## Overview - -To use LaunchDarkly in an app that uses React Server Components (RSC), the **current recommended approach** is: - -1. **Create the browser client** with `createClient` from `@launchdarkly/react-sdk` in a **shared** module (e.g. `ld-client.ts`) that does not import any server-only code. That same module can run on server and client; on the server, `createClient` returns a noop client, so the browser SDK is never loaded in server bundles. - -2. **Create the server session** in **server-only** code (e.g. `ld-server.ts`) with your LaunchDarkly server SDK and `createLDServerSession` from `@launchdarkly/react-sdk/server`, passing the request context directly. - -3. **Use the right client per boundary:** In Server Components, import the server session from `ld-server` and use `await ldServer.boolVariation(key, default)` (or the appropriate typed method). In Client Components, import the browser client from `ld-client` and use `ldClient.variation()`, `ldClient.on()`, `ldClient.waitForInitialization()`, etc. - -## Entry points and types - -- **Main entry (`@launchdarkly/react-sdk`):** `createClient(clientSideID, context, options?)` returns an `LDReactClient`. On the server it is a noop; in the browser it is the real client. Options are `LDReactClientOptions` (e.g. streaming, bootstrap). The main entry uses `'client-only'` so it is not bundled for the server. - -- **Server entry (`@launchdarkly/react-sdk/server`):** `createLDServerSession(client, context)` accepts any `LDServerBaseClient` (the minimal structural type satisfied by any LD server SDK, e.g. from the Node SDK) and an `LDContext`. Returns an `LDServerSession` with typed evaluation methods (`boolVariation`, `numberVariation`, `stringVariation`, `jsonVariation`). The server entry uses `'server-only'`. - -## Intended flow (step-by-step) - -1. **Create the browser client in a shared module.** In a file that is safe to import from both Server and Client Components (e.g. `app/lib/ld-client.ts`), call `createClient(clientSideID, context, options)` from `@launchdarkly/react-sdk`. Export the client (e.g. default export `ldClient`). Do not import `ld-server` or `@launchdarkly/react-sdk/server` here. On the server this client is a noop; in the browser it is the real client. You can call `ldClient.start()` in this module so the client starts when used in the browser. - -2. **Create the server session in server-only code.** In a file that is only ever imported by Server Components or other server-only modules (e.g. `app/lib/ld-server.ts`), create your LaunchDarkly server client (e.g. with `init()` from `@launchdarkly/node-server-sdk`). Obtain the `LDContext` for the current request (e.g. from session, headers, or cookies). Call `createLDServerSession(serverClient, context)` from `@launchdarkly/react-sdk/server` and export the result (e.g. default export `ldServer`). - -3. **Use the client.** In **Server Components**, import the server session from the server-only module (e.g. `ldServer` from `./lib/ld-server`) and call `await ldServer.boolVariation(flagKey, defaultValue)` (or `numberVariation`, `stringVariation`, `jsonVariation` as appropriate). In **Client Components**, import the browser client from the shared module (e.g. `ldClient` from `./lib/ld-client`) and use `ldClient.variation()`, `ldClient.on()`, `ldClient.waitForInitialization()`, etc., as needed. - -## Context - -The `LDContext` is passed directly to `createLDServerSession(client, context)` at session creation time. Build your context from whatever source is appropriate for the current request — headers, cookies, session data, etc. Each request should create a session with the context for that request. - -## Code examples - -The following aligns with the server-and-client example in the repo. - -**Shared module: browser client (`app/lib/ld-client.ts`)** - -Do not import server-only modules here. Safe to import from both Server and Client Components. - -```ts -import { createClient } from '@launchdarkly/react-sdk'; -import { defaultContext } from './ld-context'; - -const ldClient = createClient( - process.env.LD_CLIENT_SIDE_ID || 'test-client-side-id', - defaultContext, - { - streaming: true, - }, -); - -ldClient.start(); - -export default ldClient; -``` - -**Server-only: server client (`app/lib/ld-server.ts`)** - -This module must only be imported by Server Components or other server-only code. Do not import it from any `'use client'` component. - -```ts -import { init } from '@launchdarkly/node-server-sdk'; -import { createLDServerSession } from '@launchdarkly/react-sdk/server'; -import { defaultContext } from './ld-context'; - -const ldBaseClient = init(process.env.LAUNCHDARKLY_SDK_KEY || ''); -const ldServer = createLDServerSession(ldBaseClient, defaultContext); -export default ldServer; -``` - -**Server Component (e.g. `app/page.tsx`)** - -```tsx -import ldServer from './lib/ld-server'; -import ClientRendered from './client-rendered'; - -const flagKey = 'sample-feature'; - -export default async function Home() { - const serverFlagValue = await ldServer.boolVariation(flagKey, false); - return ( - <> -

Server: {flagKey} is {serverFlagValue ? 'on' : 'off'}

- - - ); -} -``` - -**Client Component (e.g. `app/client-rendered.tsx`)** - -Import `ldClient` from the shared module so server-only code is not pulled into the client bundle. - -```tsx -'use client'; - -import ldClient from './lib/ld-client'; -import { useEffect, useState } from 'react'; - -const flagKey = 'sample-feature'; - -export default function ClientRendered() { - const [isOn, setIsOn] = useState(false); - - useEffect(() => { - const updateFlag = () => { - const value = ldClient.variation(flagKey, false); - setIsOn(!!value); - }; - ldClient.on(`change:${flagKey}`, updateFlag); - ldClient.waitForInitialization().then(() => updateFlag()); - return () => ldClient.off(`change:${flagKey}`, updateFlag); - }, []); - - return ( -

Client: {flagKey} is {isOn ? 'on' : 'off'}

- ); -} -``` - -## Import boundaries - -- **Shared:** Import from `app/lib/ld-client` in Client Components. The `ld-client` module must not import any server-only code. -- **Server only:** Import from `app/lib/ld-server` only in Server Components or server-only modules. Do not import `ld-server` in any `'use client'` file. - -## Entry points and tree-shaking - -The main entry (`@launchdarkly/react-sdk`) is built with `'client-only'`, so it is not bundled for the server. The server entry (`@launchdarkly/react-sdk/server`) is built with `'server-only'`. If you only use the main entry in client bundles, server code is not included. Prefer importing `ld-server` only in server code so Client Components never pull in the server module. diff --git a/packages/sdk/react/temp_docs/example-usage.md b/packages/sdk/react/temp_docs/example-usage.md index cccd7b25ca..d2404018be 100644 --- a/packages/sdk/react/temp_docs/example-usage.md +++ b/packages/sdk/react/temp_docs/example-usage.md @@ -97,37 +97,66 @@ function App() { ## 2. Server-only app (Next.js RSC) -Use when you only need flag evaluation in Server Components. No browser client; the Node SDK plus `createLDServerSession` provide a request-scoped session bound to a context. +Use when you only need flag evaluation in Server Components. No browser client; the Node SDK plus +`createLDServerSession` provide a request-scoped session bound to a context. `useLDServerSession` +lets any nested Server Component retrieve that session without prop drilling. -### Create the server session +### Singleton base client (module-level) -Create the LaunchDarkly Node client and call `createLDServerSession(ldClient, context)`, passing the context for the current request directly. +Initialize the Node SDK client once at module level. This instance is shared across all requests. ```ts -// /app/page.tsx (conceptually; in the example everything lives in page.tsx) +// /app/page.tsx (module-level, outside the component) import { init } from '@launchdarkly/node-server-sdk'; import { createLDServerSession } from '@launchdarkly/react-sdk/server'; +// Singleton: initialized once per Node.js process, shared across all requests. const ldBaseClient = init(process.env.LAUNCHDARKLY_SDK_KEY || ''); - -const context = { - kind: 'user', - key: 'example-user-key', - name: 'Sandy', -}; - -const serverSession = createLDServerSession(ldBaseClient, context); ``` -### Use in a Server Component +### Create the session per request (inside the component) -Import the server session only in Server Components (or server-only modules). Call `await serverSession.boolVariation(flagKey, defaultValue)`. +Call `createLDServerSession` inside the Server Component where you have access to the request +(headers, cookies, auth tokens, or query parameters). The session is bound to that request's +context and stored in React's `cache()` for the duration of the render. ```tsx // /app/page.tsx -export default async function Home() { +export default async function Home({ + searchParams, +}: { + searchParams: Promise<{ userId?: string; userName?: string }>; +}) { await ldBaseClient.waitForInitialization({ timeout: 10 }); - const featureFlag = await serverSession.boolVariation(flagKey, false); + + // Build context from the request. In production this comes from auth/cookies/session; + // here query parameters are used to make per-user isolation easy to observe. + const { userId = 'anonymous-user', userName = 'Anonymous' } = await searchParams; + const context = { kind: 'user' as const, key: userId, name: userName }; + + // Creates the session and stores it in React's per-request cache. + // Nested Server Components can call useLDServerSession() to retrieve it. + createLDServerSession(ldBaseClient, context); + + return ; +} +``` + +### Retrieve the session in nested Server Components + +`useLDServerSession()` reads the session from React's cache — no props required. Each request +has its own isolated cache instance, so sessions for different users never interfere. + +```tsx +// /app/FeatureSection.tsx +import { useLDServerSession } from '@launchdarkly/react-sdk/server'; + +const flagKey = process.env.LAUNCHDARKLY_FLAG_KEY || 'sample-feature'; + +export default async function FeatureSection() { + // React's cache() ensures this is the session created for the current request. + const session = useLDServerSession(); + const featureFlag = await session!.boolVariation(flagKey, false); return <>{featureFlag ? 'Hello world' : 'Hello world disabled'}; } @@ -172,29 +201,31 @@ export const defaultContext: LDContextStrict = { }; ``` -### Server-only: server client +### Server-only: base client singleton -Create the server client in a **server-only** module. Never import this file from any `'use client'` component. +Export the Node SDK client at module level. Session creation (which binds a context) happens +per-request in the root Server Component. ```ts -// /app/lib/ld-server.ts +// /app/lib/ld-base-client.ts import { init } from '@launchdarkly/node-server-sdk'; -import { createLDServerSession } from '@launchdarkly/react-sdk/server'; -import { defaultContext } from './ld-context'; +// Singleton: one client per Node.js process, shared across all requests. const ldBaseClient = init(process.env.LAUNCHDARKLY_SDK_KEY || ''); -const ldServer = createLDServerSession(ldBaseClient, defaultContext); - -export default ldServer; +export default ldBaseClient; ``` -### Server Component: evaluate flags at request time +### Server Component: create the session and evaluate flags at request time -Import `ld-server` only in Server Components. Use `await ldServer.boolVariation(key, default)`. +Import `ld-base-client` only in Server Components. Call `createLDServerSession` inside the +component where you have access to the request context. Nested Server Components can then call +`useLDServerSession()` to retrieve the cached session without prop drilling. ```tsx // /app/page.tsx -import ldServer from './lib/ld-server'; +import { createLDServerSession } from '@launchdarkly/react-sdk/server'; +import ldBaseClient from './lib/ld-base-client'; +import { defaultContext } from './lib/ld-context'; import ServerSection from './server-section'; import ClientShell from './client-shell'; import ServerContent from './server-content'; @@ -202,6 +233,10 @@ import ServerContent from './server-content'; const FLAG_KEY = 'sample-feature'; export default async function Home() { + // Create a per-request session. In production derive context from auth/cookies/headers. + // createLDServerSession also stores the session in React's cache() so nested Server + // Components can retrieve it via useLDServerSession() — no prop drilling needed. + const ldServer = createLDServerSession(ldBaseClient, defaultContext); const flagValue = await ldServer.boolVariation(FLAG_KEY, false); return ( @@ -218,15 +253,17 @@ export default async function Home() { ### Nested Server Component -Server components can evaluate flags and render client components as children (client “islands”). +Nested Server Components use `useLDServerSession()` to retrieve the session from React's +per-request cache. They can also render client components as children (client "islands"). ```tsx // /app/server-section.tsx -import ldServer from './lib/ld-server'; +import { useLDServerSession } from '@launchdarkly/react-sdk/server'; import ClientIsland from './client-island'; export default async function ServerSection() { - const flagValue = await ldServer.boolVariation(FLAG_KEY, false); + const session = useLDServerSession(); + const flagValue = await session!.boolVariation(FLAG_KEY, false); return ( <> @@ -243,10 +280,11 @@ You can pass a Server Component as `children` to a Client Component. The server ```tsx // /app/server-content.tsx -import ldServer from './lib/ld-server'; +import { useLDServerSession } from '@launchdarkly/react-sdk/server'; export default async function ServerContent() { - const flagValue = await ldServer.boolVariation(FLAG_KEY, false); + const session = useLDServerSession(); + const flagValue = await session!.boolVariation(FLAG_KEY, false); return ; } ``` From ef011c49b5000e22bc496ac6e9f1b9c6b786ebf3 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Mon, 16 Mar 2026 09:37:11 -0500 Subject: [PATCH 3/4] chore: pr comments --- .../react/__tests__/server/createLDServerSession.test.ts | 2 +- packages/sdk/react/examples/.gitignore | 3 +++ packages/sdk/react/src/server/LDServerSession.ts | 6 +++--- 3 files changed, 7 insertions(+), 4 deletions(-) create mode 100644 packages/sdk/react/examples/.gitignore diff --git a/packages/sdk/react/__tests__/server/createLDServerSession.test.ts b/packages/sdk/react/__tests__/server/createLDServerSession.test.ts index 3d392f7d10..6cf44be109 100644 --- a/packages/sdk/react/__tests__/server/createLDServerSession.test.ts +++ b/packages/sdk/react/__tests__/server/createLDServerSession.test.ts @@ -171,7 +171,7 @@ describe('given a browser environment (window defined)', () => { it('throws an error instead of returning a no-op session', () => { const client = makeMockBaseClient(); expect(() => createLDServerSession(client, context)).toThrow( - 'createLDServerSession must only be called on the server.', + 'createLDServerWrapper must only be called on the server.', ); }); }); diff --git a/packages/sdk/react/examples/.gitignore b/packages/sdk/react/examples/.gitignore new file mode 100644 index 0000000000..8737a98739 --- /dev/null +++ b/packages/sdk/react/examples/.gitignore @@ -0,0 +1,3 @@ +# Temporary ignore to keep PRs manageable. + +server-only diff --git a/packages/sdk/react/src/server/LDServerSession.ts b/packages/sdk/react/src/server/LDServerSession.ts index 512ca2c5cd..50e5226eea 100644 --- a/packages/sdk/react/src/server/LDServerSession.ts +++ b/packages/sdk/react/src/server/LDServerSession.ts @@ -6,8 +6,8 @@ import { LDServerSession } from './LDClient'; import { LDServerBaseClient } from './LDServerBaseClient'; // cache() creates a per-request memoized store — each React render tree (request) -// gets its own isolated instance. The store is populated by LDIsomorphicProvider -// and read by serverBoolVariation / useBoolVariation. +// gets its own isolated instance. The store is populated by createLDServerSession +// and read by useLDServerSession. const withCache = cache(() => ({ session: null as LDServerSession | null })); /** @@ -57,7 +57,7 @@ export function createLDServerWrapper( ): LDServerSession { if (!isServer()) { throw new Error( - 'createLDServerSession must only be called on the server. ' + + 'createLDServerWrapper must only be called on the server. ' + 'Ensure this module is not imported from client components.', ); } From 80eca872df02866a046dc6a2381a240a506c3526 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Mon, 16 Mar 2026 12:44:19 -0500 Subject: [PATCH 4/4] chore: pr comments --- packages/sdk/react/src/server/LDServerSession.ts | 5 ----- 1 file changed, 5 deletions(-) diff --git a/packages/sdk/react/src/server/LDServerSession.ts b/packages/sdk/react/src/server/LDServerSession.ts index 50e5226eea..4b89be899a 100644 --- a/packages/sdk/react/src/server/LDServerSession.ts +++ b/packages/sdk/react/src/server/LDServerSession.ts @@ -31,11 +31,6 @@ export function isServer(): boolean { * @throws {Error} If called in a browser environment. This function must only be called on the * server. Ensure the module that calls this is not imported from client components. * - * @privateRemarks - * TODO: I think throwing an error might be better than just silently failing here. - * While the client -> server boundary is most likely a security loosening boundary, - * the server -> client boundary needs to be considered more carefully. Open to discussion. - * * @example * ```ts * // lib/ld-server.ts