Skip to content

Commit a8b52f4

Browse files
drewpckinyoklion
andauthored
feat: Support for providing custom contexts (#313)
**Requirements** - [X] I have added test coverage for new or changed functionality - [X] I have followed the repository's [pull request submission guidelines](../blob/main/CONTRIBUTING.md#submitting-pull-requests) - [ ] I have validated my changes against all supported platform versions _How do I validate my changes against all supported platform versions?_ **Related issues** #127 **Describe the solution you've provided** This PR enables developers to provide a custom React Context to the React SDK along-side a pre-initialized LDClient object. This allows applications to create one client per environment and/or project and create a unique React Context that corresponds with each client. Also, in a microfrontend situation where multiple independent React applications are loaded on the same page, this custom React Context feature would allow a parent application to create the client and associated React Context. Then any child application loaded on the page could leverage those existing client and context objects. Here's an example of how the React Contexts can be used together: ```javascript <LDProvider1> <LDProvider2> <CustomContext1.Consumer> {({ flags }) => { return ( <> <span>consumer 1, flag 1 is {safeValue(flags.context1TestFlag)}</span> <span>consumer 1, flag 2 is {safeValue(flags.context2TestFlag)}</span> </> ); }} </CustomContext1.Consumer> <CustomContext2.Consumer> {({ flags }) => { return ( <> <span>consumer 2, flag 1 is {safeValue(flags.context1TestFlag)}</span> <span>consumer 2, flag 2 is {safeValue(flags.context2TestFlag)}</span> </> ); }} </CustomContext2.Consumer> </LDProvider2> </LDProvider1> ``` **Describe alternatives you've considered** I am unaware of alternatives to providing a custom React context into the library because this library creates its own context at runtime. **Additional context** Co-authored-by: Ryan Lamb <[email protected]>
1 parent 21c2402 commit a8b52f4

12 files changed

+257
-61
lines changed

src/asyncWithLDProvider.test.tsx

Lines changed: 118 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,14 +2,13 @@ import React from 'react';
22
import '@testing-library/dom';
33
import '@testing-library/jest-dom';
44
import { render } from '@testing-library/react';
5-
import { initialize, LDContext, LDFlagChangeset, LDOptions } from 'launchdarkly-js-client-sdk';
5+
import { initialize, LDClient, LDContext, LDFlagChangeset, LDOptions } from 'launchdarkly-js-client-sdk';
66
import { AsyncProviderConfig, LDReactOptions } from './types';
7-
import { Consumer } from './context';
7+
import { Consumer, reactSdkContextFactory } from './context';
88
import asyncWithLDProvider from './asyncWithLDProvider';
99
import wrapperOptions from './wrapperOptions';
1010
import { fetchFlags } from './utils';
1111

12-
1312
jest.mock('launchdarkly-js-client-sdk', () => {
1413
const actual = jest.requireActual('launchdarkly-js-client-sdk');
1514

@@ -351,4 +350,120 @@ describe('asyncWithLDProvider', () => {
351350

352351
expect(receivedNode).toHaveTextContent('{"testFlag":false}');
353352
});
353+
354+
test('custom context is provided to consumer', async () => {
355+
const CustomContext = reactSdkContextFactory();
356+
const customLDClient = {
357+
on: jest.fn((_: string, cb: () => void) => {
358+
cb();
359+
}),
360+
off: jest.fn(),
361+
allFlags: jest.fn().mockReturnValue({ 'context-test-flag': true }),
362+
variation: jest.fn((_: string, v) => v),
363+
waitForInitialization: jest.fn(),
364+
};
365+
const config: AsyncProviderConfig = {
366+
clientSideID,
367+
ldClient: customLDClient as unknown as LDClient,
368+
reactOptions: {
369+
reactContext: CustomContext,
370+
},
371+
};
372+
const originalUtilsModule = jest.requireActual('./utils');
373+
mockFetchFlags.mockImplementation(originalUtilsModule.fetchFlags);
374+
375+
const LDProvider = await asyncWithLDProvider(config);
376+
const LaunchDarklyApp = (
377+
<LDProvider>
378+
<CustomContext.Consumer>
379+
{({ flags }) => {
380+
return (
381+
<span>
382+
flag is {flags.contextTestFlag === undefined ? 'undefined' : JSON.stringify(flags.contextTestFlag)}
383+
</span>
384+
);
385+
}}
386+
</CustomContext.Consumer>
387+
</LDProvider>
388+
);
389+
390+
const { findByText } = render(LaunchDarklyApp);
391+
expect(await findByText('flag is true')).not.toBeNull();
392+
393+
const receivedNode = await renderWithConfig({ clientSideID });
394+
expect(receivedNode).not.toHaveTextContent('{"contextTestFlag":true}');
395+
});
396+
397+
test('multiple providers', async () => {
398+
const customLDClient1 = {
399+
on: jest.fn((_: string, cb: () => void) => {
400+
cb();
401+
}),
402+
off: jest.fn(),
403+
allFlags: jest.fn().mockReturnValue({ 'context1-test-flag': true }),
404+
variation: jest.fn((_: string, v) => v),
405+
waitForInitialization: jest.fn(),
406+
};
407+
const customLDClient2 = {
408+
on: jest.fn((_: string, cb: () => void) => {
409+
cb();
410+
}),
411+
off: jest.fn(),
412+
allFlags: jest.fn().mockReturnValue({ 'context2-test-flag': true }),
413+
variation: jest.fn((_: string, v) => v),
414+
waitForInitialization: jest.fn(),
415+
};
416+
const originalUtilsModule = jest.requireActual('./utils');
417+
mockFetchFlags.mockImplementation(originalUtilsModule.fetchFlags);
418+
419+
const CustomContext1 = reactSdkContextFactory();
420+
const LDProvider1 = await asyncWithLDProvider({
421+
clientSideID,
422+
ldClient: customLDClient1 as unknown as LDClient,
423+
reactOptions: {
424+
reactContext: CustomContext1,
425+
},
426+
});
427+
const CustomContext2 = reactSdkContextFactory();
428+
const LDProvider2 = await asyncWithLDProvider({
429+
clientSideID,
430+
ldClient: customLDClient2 as unknown as LDClient,
431+
reactOptions: {
432+
reactContext: CustomContext2,
433+
},
434+
});
435+
const safeValue = (val?: boolean) => (val === undefined ? 'undefined' : JSON.stringify(val));
436+
const LaunchDarklyApp = (
437+
<LDProvider1>
438+
<LDProvider2>
439+
<CustomContext1.Consumer>
440+
{({ flags }) => {
441+
return (
442+
<>
443+
<span>consumer 1, flag 1 is {safeValue(flags.context1TestFlag)}</span>
444+
<span>consumer 1, flag 2 is {safeValue(flags.context2TestFlag)}</span>
445+
</>
446+
);
447+
}}
448+
</CustomContext1.Consumer>
449+
<CustomContext2.Consumer>
450+
{({ flags }) => {
451+
return (
452+
<>
453+
<span>consumer 2, flag 1 is {safeValue(flags.context1TestFlag)}</span>
454+
<span>consumer 2, flag 2 is {safeValue(flags.context2TestFlag)}</span>
455+
</>
456+
);
457+
}}
458+
</CustomContext2.Consumer>
459+
</LDProvider2>
460+
</LDProvider1>
461+
);
462+
463+
const { findByText } = render(LaunchDarklyApp);
464+
expect(await findByText('consumer 1, flag 1 is true')).not.toBeNull();
465+
expect(await findByText('consumer 1, flag 2 is undefined')).not.toBeNull();
466+
expect(await findByText('consumer 2, flag 1 is undefined')).not.toBeNull();
467+
expect(await findByText('consumer 2, flag 2 is true')).not.toBeNull();
468+
});
354469
});

src/asyncWithLDProvider.tsx

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import React, { useState, useEffect, ReactNode } from 'react';
22
import { initialize, LDFlagChangeset } from 'launchdarkly-js-client-sdk';
33
import { AsyncProviderConfig, defaultReactOptions } from './types';
4-
import { Provider } from './context';
54
import { fetchFlags, getContextOrUser, getFlattenedFlagsFromChangeset } from './utils';
65
import getFlagsProxy from './getFlagsProxy';
76
import wrapperOptions from './wrapperOptions';
@@ -104,7 +103,9 @@ export default async function asyncWithLDProvider(config: AsyncProviderConfig) {
104103
// unproxiedFlags is for internal use only. Exclude it from context.
105104
const { unproxiedFlags: _, ...rest } = ldData;
106105

107-
return <Provider value={rest}>{children}</Provider>;
106+
const { reactContext } = reactOptions;
107+
108+
return <reactContext.Provider value={rest}>{children}</reactContext.Provider>;
108109
};
109110

110111
return LDProvider;

src/context.ts

Lines changed: 8 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,41 +1,17 @@
11
import { createContext } from 'react';
2-
import { LDClient, LDFlagSet } from 'launchdarkly-js-client-sdk';
3-
import { LDFlagKeyMap } from './types';
2+
import { ReactSdkContext } from './types';
43

54
/**
6-
* The sdk context stored in the Provider state and passed to consumers.
5+
* `reactSdkContextFactory` is a function useful for creating a React context for use with
6+
* all the providers and consumers in this library.
7+
*
8+
* @return a React Context
79
*/
8-
interface ReactSdkContext {
9-
/**
10-
* JavaScript proxy that will trigger a LDClient#variation call on flag read in order
11-
* to register a flag evaluation event in LaunchDarkly. Empty {} initially
12-
* until flags are fetched from the LaunchDarkly servers.
13-
*/
14-
flags: LDFlagSet;
15-
16-
/**
17-
* Map of camelized flag keys to their original unmodified form. Empty if useCamelCaseFlagKeys option is false.
18-
*/
19-
flagKeyMap: LDFlagKeyMap;
20-
21-
/**
22-
* An instance of `LDClient` from the LaunchDarkly JS SDK (`launchdarkly-js-client-sdk`).
23-
* This will be be undefined initially until initialization is complete.
24-
*
25-
* @see https://docs.launchdarkly.com/sdk/client-side/javascript
26-
*/
27-
ldClient?: LDClient;
28-
29-
/**
30-
* LaunchDarkly client initialization error, if there was one.
31-
*/
32-
error?: Error;
33-
}
34-
10+
const reactSdkContextFactory = () => createContext<ReactSdkContext>({ flags: {}, flagKeyMap: {}, ldClient: undefined });
3511
/**
3612
* @ignore
3713
*/
38-
const context = createContext<ReactSdkContext>({ flags: {}, flagKeyMap: {}, ldClient: undefined });
14+
const context = reactSdkContextFactory();
3915
const {
4016
/**
4117
* @ignore
@@ -47,5 +23,5 @@ const {
4723
Consumer,
4824
} = context;
4925

50-
export { Provider, Consumer, ReactSdkContext };
26+
export { Provider, Consumer, ReactSdkContext, reactSdkContextFactory };
5127
export default context;

src/getFlagsProxy.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ const camelizedFlags: LDFlagSet = {
1313
};
1414

1515
// cast as unknown first to be able to partially mock ldClient
16-
const ldClient = ({ variation: jest.fn((flagKey) => rawFlags[flagKey] as string) } as unknown) as LDClient;
16+
const ldClient = { variation: jest.fn((flagKey) => rawFlags[flagKey] as string) } as unknown as LDClient;
1717

1818
beforeEach(jest.clearAllMocks);
1919

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,13 +6,15 @@ import useFlags from './useFlags';
66
import useLDClient from './useLDClient';
77
import useLDClientError from './useLDClientError';
88
import { camelCaseKeys } from './utils';
9+
import { reactSdkContextFactory } from './context';
910

1011
export * from './types';
1112

1213
export {
1314
LDProvider,
1415
asyncWithLDProvider,
1516
camelCaseKeys,
17+
reactSdkContextFactory,
1618
useFlags,
1719
useLDClient,
1820
useLDClientError,

src/provider.test.tsx

Lines changed: 51 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,21 @@ jest.mock('./utils', () => {
1616
fetchFlags: jest.fn(),
1717
};
1818
});
19-
jest.mock('./context', () => ({ Provider: 'Provider' }));
19+
jest.mock('./context', () => {
20+
const originalModule = jest.requireActual('./context');
21+
22+
return {
23+
...originalModule,
24+
Provider: 'Provider',
25+
};
26+
});
2027

2128
import React, { Component } from 'react';
29+
import { render } from '@testing-library/react';
2230
import { create } from 'react-test-renderer';
2331
import { initialize, LDClient, LDContext, LDFlagChangeset, LDOptions } from 'launchdarkly-js-client-sdk';
2432
import { LDReactOptions, EnhancedComponent, ProviderConfig } from './types';
25-
import { ReactSdkContext as HocState } from './context';
33+
import { ReactSdkContext as HocState, reactSdkContextFactory } from './context';
2634
import LDProvider from './provider';
2735
import { fetchFlags } from './utils';
2836
import wrapperOptions from './wrapperOptions';
@@ -126,7 +134,7 @@ describe('LDProvider', () => {
126134

127135
test('ld client is used if passed in', async () => {
128136
options = { ...options, bootstrap: {} };
129-
const ldClient = (mockLDClient as unknown) as LDClient;
137+
const ldClient = mockLDClient as unknown as LDClient;
130138
mockInitialize.mockClear();
131139
const props: ProviderConfig = { clientSideID, ldClient };
132140
const LaunchDarklyApp = (
@@ -144,7 +152,7 @@ describe('LDProvider', () => {
144152
const context2: LDContext = { key: 'launch', kind: 'user', name: 'darkly' };
145153
options = { ...options, bootstrap: {} };
146154
const ldClient = new Promise<LDClient>((resolve) => {
147-
resolve((mockLDClient as unknown) as LDClient);
155+
resolve(mockLDClient as unknown as LDClient);
148156

149157
return;
150158
});
@@ -537,4 +545,43 @@ describe('LDProvider', () => {
537545
flagKeyMap: { testFlag: 'test-flag' },
538546
});
539547
});
548+
549+
test('custom context is provided to consumer', async () => {
550+
const CustomContext = reactSdkContextFactory();
551+
const customLDClient = {
552+
on: jest.fn((_: string, cb: () => void) => {
553+
cb();
554+
}),
555+
off: jest.fn(),
556+
allFlags: jest.fn().mockReturnValue({ 'context-test-flag': true }),
557+
variation: jest.fn((_: string, v) => v),
558+
waitForInitialization: jest.fn(),
559+
};
560+
const props: ProviderConfig = {
561+
clientSideID,
562+
ldClient: customLDClient as unknown as LDClient,
563+
reactOptions: {
564+
reactContext: CustomContext,
565+
},
566+
};
567+
const originalUtilsModule = jest.requireActual('./utils');
568+
mockFetchFlags.mockImplementation(originalUtilsModule.fetchFlags);
569+
570+
const LaunchDarklyApp = (
571+
<LDProvider {...props}>
572+
<CustomContext.Consumer>
573+
{({ flags }) => {
574+
return (
575+
<span>
576+
flag is {flags.contextTestFlag === undefined ? 'undefined' : JSON.stringify(flags.contextTestFlag)}
577+
</span>
578+
);
579+
}}
580+
</CustomContext.Consumer>
581+
</LDProvider>
582+
);
583+
584+
const { findByText } = render(LaunchDarklyApp);
585+
expect(await findByText('flag is true')).not.toBeNull();
586+
});
540587
});

src/provider.tsx

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
import React, { Component, PropsWithChildren } from 'react';
22
import { initialize, LDClient, LDFlagChangeset, LDFlagSet } from 'launchdarkly-js-client-sdk';
33
import { EnhancedComponent, ProviderConfig, defaultReactOptions, LDReactOptions } from './types';
4-
import { Provider } from './context';
54
import { camelCaseKeys, fetchFlags, getContextOrUser, getFlattenedFlagsFromChangeset } from './utils';
65
import getFlagsProxy from './getFlagsProxy';
76
import wrapperOptions from './wrapperOptions';
@@ -144,7 +143,13 @@ class LDProvider extends Component<PropsWithChildren<ProviderConfig>, ProviderSt
144143
render() {
145144
const { flags, flagKeyMap, ldClient, error } = this.state;
146145

147-
return <Provider value={{ flags, flagKeyMap, ldClient, error }}>{this.props.children}</Provider>;
146+
const { reactContext } = this.getReactOptions();
147+
148+
return (
149+
<reactContext.Provider value={{ flags, flagKeyMap, ldClient, error }}>
150+
{this.props.children}
151+
</reactContext.Provider>
152+
);
148153
}
149154
}
150155

0 commit comments

Comments
 (0)