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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 15 additions & 46 deletions packages/sdk/react/__tests__/server/createLDServerSession.test.ts
Original file line number Diff line number Diff line change
@@ -1,57 +1,26 @@
import { LDContext, LDFlagsStateOptions } from '@launchdarkly/js-server-sdk-common';
import { LDContext } from '@launchdarkly/js-server-sdk-common';

import { createLDServerSession } from '../../src/server/index';
import { makeMockServerClient } from './mockServerClient';

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('getContext() returns the context passed at creation', () => {
const client = makeMockBaseClient();
const client = makeMockServerClient();
const session = createLDServerSession(client, context);
expect(session.getContext()).toEqual(context);
});

it('initialized() delegates to the base client', () => {
const client = makeMockBaseClient();
const client = makeMockServerClient();
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();
const client = makeMockServerClient();
client.boolVariation.mockResolvedValue(true);
const session = createLDServerSession(client, context);
const result = await session.boolVariation('my-flag', false);
Expand All @@ -60,7 +29,7 @@ it('boolVariation() calls base client with bound context', async () => {
});

it('numberVariation() calls base client with bound context', async () => {
const client = makeMockBaseClient();
const client = makeMockServerClient();
client.numberVariation.mockResolvedValue(42);
const session = createLDServerSession(client, context);
const result = await session.numberVariation('my-flag', 0);
Expand All @@ -69,7 +38,7 @@ it('numberVariation() calls base client with bound context', async () => {
});

it('stringVariation() calls base client with bound context', async () => {
const client = makeMockBaseClient();
const client = makeMockServerClient();
client.stringVariation.mockResolvedValue('hello');
const session = createLDServerSession(client, context);
const result = await session.stringVariation('my-flag', 'default');
Expand All @@ -78,7 +47,7 @@ it('stringVariation() calls base client with bound context', async () => {
});

it('jsonVariation() calls base client with bound context', async () => {
const client = makeMockBaseClient();
const client = makeMockServerClient();
const json = { key: 'value' };
client.jsonVariation.mockResolvedValue(json);
const session = createLDServerSession(client, context);
Expand All @@ -88,7 +57,7 @@ it('jsonVariation() calls base client with bound context', async () => {
});

it('boolVariationDetail() calls base client with bound context', async () => {
const client = makeMockBaseClient();
const client = makeMockServerClient();
const detail = { value: true, variationIndex: 1, reason: { kind: 'RULE_MATCH' as const } };
// @ts-ignore — valid LDEvaluationDetailTyped<boolean> shape; mock type is too narrow
client.boolVariationDetail.mockResolvedValue(detail);
Expand All @@ -99,7 +68,7 @@ it('boolVariationDetail() calls base client with bound context', async () => {
});

it('numberVariationDetail() calls base client with bound context', async () => {
const client = makeMockBaseClient();
const client = makeMockServerClient();
const detail = { value: 42, variationIndex: 1, reason: { kind: 'RULE_MATCH' as const } };
// @ts-ignore — valid LDEvaluationDetailTyped<number> shape; mock type is too narrow
client.numberVariationDetail.mockResolvedValue(detail);
Expand All @@ -110,7 +79,7 @@ it('numberVariationDetail() calls base client with bound context', async () => {
});

it('stringVariationDetail() calls base client with bound context', async () => {
const client = makeMockBaseClient();
const client = makeMockServerClient();
const detail = { value: 'hello', variationIndex: 1, reason: { kind: 'RULE_MATCH' as const } };
// @ts-ignore — valid LDEvaluationDetailTyped<string> shape; mock type is too narrow
client.stringVariationDetail.mockResolvedValue(detail);
Expand All @@ -121,7 +90,7 @@ it('stringVariationDetail() calls base client with bound context', async () => {
});

it('jsonVariationDetail() calls base client with bound context', async () => {
const client = makeMockBaseClient();
const client = makeMockServerClient();
const detail = {
value: { key: 'value' },
variationIndex: 1,
Expand All @@ -136,14 +105,14 @@ it('jsonVariationDetail() calls base client with bound context', async () => {
});

it('allFlagsState() calls base client with bound context', async () => {
const client = makeMockBaseClient();
const client = makeMockServerClient();
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 client = makeMockServerClient();
const session = createLDServerSession(client, context);
const options = { clientSideOnly: true };
await session.allFlagsState(options);
Expand All @@ -165,7 +134,7 @@ describe('given a browser environment (window defined)', () => {
});

it('throws an error instead of returning a no-op session', () => {
const client = makeMockBaseClient();
const client = makeMockServerClient();
expect(() => createLDServerSession(client, context)).toThrow(
'createLDServerWrapper must only be called on the server.',
);
Expand Down
34 changes: 34 additions & 0 deletions packages/sdk/react/__tests__/server/mockServerClient.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { LDContext, LDFlagsStateOptions } from '@launchdarkly/js-server-sdk-common';

export function makeMockServerClient() {
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 })),
}),
),
track: jest.fn(() => Promise.resolve()),
};
}
57 changes: 57 additions & 0 deletions packages/sdk/react/__tests__/server/rscTracking.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { LDContext } from '@launchdarkly/js-server-sdk-common';

import { createLDServerWrapper } from '../../src/server/LDServerSession';
import { makeMockServerClient } from './mockServerClient';

const context: LDContext = { kind: 'user', key: 'test-user' };

it('calls track once after the first variation call', async () => {
const client = makeMockServerClient();
const session = createLDServerWrapper(client, context);

await session.boolVariation('flag-1', false);

expect(client.track).toHaveBeenCalledTimes(1);
expect(client.track).toHaveBeenCalledWith('$ld:react-sdk:rsc-evaluation', context);
});

it('does not call track again on subsequent variation calls', async () => {
const client = makeMockServerClient();
const session = createLDServerWrapper(client, context);

await session.boolVariation('flag-1', false);
await session.stringVariation('flag-2', 'default');
await session.numberVariation('flag-3', 0);
await session.jsonVariation('flag-4', {});
await session.boolVariationDetail('flag-5', false);
await session.numberVariationDetail('flag-6', 0);
await session.stringVariationDetail('flag-7', 'default');
await session.jsonVariationDetail('flag-8', {});

expect(client.track).toHaveBeenCalledTimes(1);
});

it('does not call track if no variation calls are made', () => {
const client = makeMockServerClient();
createLDServerWrapper(client, context);

expect(client.track).not.toHaveBeenCalled();
});

it('does not call track for allFlagsState', async () => {
const client = makeMockServerClient();
const session = createLDServerWrapper(client, context);

await session.allFlagsState();

expect(client.track).not.toHaveBeenCalled();
});

it('does not call track for initialized', () => {
const client = makeMockServerClient();
const session = createLDServerWrapper(client, context);

session.initialized();

expect(client.track).not.toHaveBeenCalled();
});
11 changes: 7 additions & 4 deletions packages/sdk/react/examples/server-only/app/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -30,17 +30,20 @@
flex-direction: column;
align-items: center;
justify-content: center;
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
font-size: calc(10px + 2vmin);
padding: 2rem;
gap: 1rem;
}

.app--on { background-color: #00844B; }
.app--off { background-color: #373841; }
.app--on {
background-color: #00844b;
}
.app--off {
background-color: #373841;
}

.context {
font-size: 0.7em;
opacity: 0.75;
}

16 changes: 3 additions & 13 deletions packages/sdk/react/examples/server-only/tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,11 +1,7 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
Expand All @@ -23,9 +19,7 @@
}
],
"paths": {
"@/*": [
"./*"
]
"@/*": ["./*"]
}
},
"include": [
Expand All @@ -35,9 +29,5 @@
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules",
"e2e",
"playwright.config.ts"
]
"exclude": ["node_modules", "e2e", "playwright.config.ts"]
}
5 changes: 5 additions & 0 deletions packages/sdk/react/src/server/LDServerBaseClient.ts
Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Real review for this starts here. Previous files are lint and test changes

Original file line number Diff line number Diff line change
Expand Up @@ -88,4 +88,9 @@ export interface LDServerBaseClient {
* Builds an object encapsulating the state of all feature flags for a given context.
*/
allFlagsState(context: LDContext, options?: LDFlagsStateOptions): Promise<LDFlagsState>;

/**
* Tracks that a context performed an event.
*/
track(key: string, context: LDContext, data?: any, metricValue?: number): void;
}
56 changes: 44 additions & 12 deletions packages/sdk/react/src/server/LDServerSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,21 +57,53 @@ export function createLDServerWrapper(
);
}

// Batch in a single event so we can track the usage of this SDK for React Server Components.
let tracked = false;

function trackRscUsage() {
if (tracked) {
return;
}
tracked = true;
// TODO: placeholder event name for now until we are sure this is a good idea.
client.track('$ld:react-sdk:rsc-evaluation', context);
}

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),
boolVariation: (key, defaultValue) => {
trackRscUsage();
return client.boolVariation(key, context, defaultValue);
},
numberVariation: (key, defaultValue) => {
trackRscUsage();
return client.numberVariation(key, context, defaultValue);
},
stringVariation: (key, defaultValue) => {
trackRscUsage();
return client.stringVariation(key, context, defaultValue);
},
jsonVariation: (key, defaultValue) => {
trackRscUsage();
return client.jsonVariation(key, context, defaultValue);
},
boolVariationDetail: (key, defaultValue) => {
trackRscUsage();
return client.boolVariationDetail(key, context, defaultValue);
},
numberVariationDetail: (key, defaultValue) => {
trackRscUsage();
return client.numberVariationDetail(key, context, defaultValue);
},
stringVariationDetail: (key, defaultValue) => {
trackRscUsage();
return client.stringVariationDetail(key, context, defaultValue);
},
jsonVariationDetail: (key, defaultValue) => {
trackRscUsage();
return client.jsonVariationDetail(key, context, defaultValue);
},
allFlagsState: (options?: LDFlagsStateOptions) => client.allFlagsState(context, options),
};
}
Expand Down
Loading