diff --git a/packages/sdk/react/__tests__/server/createLDServerSession.test.ts b/packages/sdk/react/__tests__/server/createLDServerSession.test.ts index a3e2dbe433..a232fb42c3 100644 --- a/packages/sdk/react/__tests__/server/createLDServerSession.test.ts +++ b/packages/sdk/react/__tests__/server/createLDServerSession.test.ts @@ -1,49 +1,18 @@ -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); @@ -51,7 +20,7 @@ it('initialized() delegates to the base client', () => { }); 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); @@ -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); @@ -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'); @@ -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); @@ -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 shape; mock type is too narrow client.boolVariationDetail.mockResolvedValue(detail); @@ -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 shape; mock type is too narrow client.numberVariationDetail.mockResolvedValue(detail); @@ -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 shape; mock type is too narrow client.stringVariationDetail.mockResolvedValue(detail); @@ -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, @@ -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); @@ -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.', ); diff --git a/packages/sdk/react/__tests__/server/mockServerClient.ts b/packages/sdk/react/__tests__/server/mockServerClient.ts new file mode 100644 index 0000000000..5d74f86d98 --- /dev/null +++ b/packages/sdk/react/__tests__/server/mockServerClient.ts @@ -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()), + }; +} diff --git a/packages/sdk/react/__tests__/server/rscTracking.test.ts b/packages/sdk/react/__tests__/server/rscTracking.test.ts new file mode 100644 index 0000000000..8fb41e3970 --- /dev/null +++ b/packages/sdk/react/__tests__/server/rscTracking.test.ts @@ -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(); +}); diff --git a/packages/sdk/react/examples/server-only/app/styles.css b/packages/sdk/react/examples/server-only/app/styles.css index 7da30ae1e5..601bd4d076 100644 --- a/packages/sdk/react/examples/server-only/app/styles.css +++ b/packages/sdk/react/examples/server-only/app/styles.css @@ -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; } - diff --git a/packages/sdk/react/examples/server-only/tsconfig.json b/packages/sdk/react/examples/server-only/tsconfig.json index 88102d75f3..d45e92c431 100644 --- a/packages/sdk/react/examples/server-only/tsconfig.json +++ b/packages/sdk/react/examples/server-only/tsconfig.json @@ -1,11 +1,7 @@ { "compilerOptions": { "target": "ES2017", - "lib": [ - "dom", - "dom.iterable", - "esnext" - ], + "lib": ["dom", "dom.iterable", "esnext"], "allowJs": true, "skipLibCheck": true, "strict": true, @@ -23,9 +19,7 @@ } ], "paths": { - "@/*": [ - "./*" - ] + "@/*": ["./*"] } }, "include": [ @@ -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"] } diff --git a/packages/sdk/react/src/server/LDServerBaseClient.ts b/packages/sdk/react/src/server/LDServerBaseClient.ts index c9e7854315..8958dd8b82 100644 --- a/packages/sdk/react/src/server/LDServerBaseClient.ts +++ b/packages/sdk/react/src/server/LDServerBaseClient.ts @@ -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; + + /** + * Tracks that a context performed an event. + */ + track(key: string, context: LDContext, data?: any, metricValue?: number): void; } diff --git a/packages/sdk/react/src/server/LDServerSession.ts b/packages/sdk/react/src/server/LDServerSession.ts index 5e92671458..63efca5019 100644 --- a/packages/sdk/react/src/server/LDServerSession.ts +++ b/packages/sdk/react/src/server/LDServerSession.ts @@ -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), }; }