Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Expose LDProvider as a standalone component #28

Closed
wants to merge 4 commits into from
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
16 changes: 16 additions & 0 deletions src/__snapshots__/provider.test.tsx.snap
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP

exports[`LDProvider render app 1`] = `
<Provider
value={
Object {
"flags": Object {},
"ldClient": undefined,
}
}
>
<div>
My App
</div>
</Provider>
`;
3 changes: 2 additions & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,9 @@
import LDProvider from './provider';
import withLDProvider from './withLDProvider';
import asyncWithLDProvider from './asyncWithLDProvider';
import withLDConsumer from './withLDConsumer';
import useFlags from './useFlags';
import useLDClient from './useLDClient';
import camelCaseKeys from './utils';

export { withLDProvider, withLDConsumer, useFlags, useLDClient, asyncWithLDProvider, camelCaseKeys };
export { LDProvider, withLDProvider, withLDConsumer, useFlags, useLDClient, asyncWithLDProvider, camelCaseKeys };
284 changes: 284 additions & 0 deletions src/provider.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,284 @@
jest.mock('./initLDClient', () => jest.fn());
jest.mock('./context', () => ({ Provider: 'Provider' }));

import * as React from 'react';
import { create } from 'react-test-renderer';
import { shallow } from 'enzyme';
import { LDFlagChangeset, LDFlagSet, LDOptions, LDUser } from 'launchdarkly-js-client-sdk';
import initLDClient from './initLDClient';
import { LDReactOptions, EnhancedComponent, defaultReactOptions, ProviderConfig } from './types';
import { LDContext as HocState } from './context';
import LDProvider from './provider';

const clientSideID = 'deadbeef';
const App = () => <div>My App</div>;
const mockInitLDClient = initLDClient as jest.Mock;
const mockFlags = { testFlag: true, anotherTestFlag: true };
const mockLDClient = {
on: jest.fn((e: string, cb: () => void) => {
cb();
}),
};

describe('LDProvider', () => {
beforeEach(() => {
mockInitLDClient.mockImplementation(() => ({
flags: mockFlags,
ldClient: mockLDClient,
}));
});

afterEach(() => {
jest.resetAllMocks();
});

test('render app', () => {
const props: ProviderConfig = { clientSideID };
const LaunchDarklyApp = (
<LDProvider {...props}>
<App />
</LDProvider>
);
const component = create(LaunchDarklyApp);
expect(component).toMatchSnapshot();
});

test('ld client is initialised correctly', async () => {
const user: LDUser = { key: 'yus', name: 'yus ng' };
const options: LDOptions = { bootstrap: {} };
const props: ProviderConfig = { clientSideID, user, options };
const LaunchDarklyApp = (
<LDProvider {...props}>
<App />
</LDProvider>
);
const instance = create(LaunchDarklyApp).root.findByType(LDProvider).instance as EnhancedComponent;

await instance.componentDidMount();
expect(mockInitLDClient).toHaveBeenCalledWith(clientSideID, user, defaultReactOptions, options, undefined);
});

test('ldClient bootstraps with empty flags', () => {
const user: LDUser = { key: 'yus', name: 'yus ng' };
const options: LDOptions = {
bootstrap: {},
};
const props: ProviderConfig = { clientSideID, user, options };
const LaunchDarklyApp = (
<LDProvider {...props}>
<App />
</LDProvider>
);
const component = shallow(LaunchDarklyApp, { disableLifecycleMethods: true });
const initialState = component.state() as HocState;

expect(initialState.flags).toEqual({});
});

test('ld client is bootstrapped correctly and transforms keys to camel case', () => {
const user: LDUser = { key: 'yus', name: 'yus ng' };
const options: LDOptions = {
bootstrap: {
'test-flag': true,
'another-test-flag': false,
$flagsState: {
'test-flag': { version: 125, variation: 0, trackEvents: true },
'another-test-flag': { version: 18, variation: 1 },
},
$valid: true,
},
};
const props: ProviderConfig = { clientSideID, user, options };
const LaunchDarklyApp = (
<LDProvider {...props}>
<App />
</LDProvider>
);
const component = shallow(LaunchDarklyApp, { disableLifecycleMethods: true });
const initialState = component.state() as HocState;

expect(mockInitLDClient).not.toHaveBeenCalled();
expect(initialState.flags).toEqual({ testFlag: true, anotherTestFlag: false });
});

test('ld client should not transform keys to camel case if option is disabled', () => {
const user: LDUser = { key: 'yus', name: 'yus ng' };
const options: LDOptions = {
bootstrap: {
'test-flag': true,
'another-test-flag': false,
},
};
const reactOptions: LDReactOptions = {
useCamelCaseFlagKeys: false,
};
const props: ProviderConfig = { clientSideID, user, options, reactOptions };
const LaunchDarklyApp = (
<LDProvider {...props}>
<App />
</LDProvider>
);
const component = shallow(LaunchDarklyApp, { disableLifecycleMethods: true });
const initialState = component.state() as HocState;

expect(mockInitLDClient).not.toHaveBeenCalled();
expect(initialState.flags).toEqual({ 'test-flag': true, 'another-test-flag': false });
});

test('ld client should transform keys to camel case if transform option is absent', () => {
const user: LDUser = { key: 'yus', name: 'yus ng' };
const options: LDOptions = {
bootstrap: {
'test-flag': true,
'another-test-flag': false,
},
};
const reactOptions: LDReactOptions = {};
const props: ProviderConfig = { clientSideID, user, options, reactOptions };
const LaunchDarklyApp = (
<LDProvider {...props}>
<App />
</LDProvider>
);
const component = shallow(LaunchDarklyApp, { disableLifecycleMethods: true });
const initialState = component.state() as HocState;

expect(mockInitLDClient).not.toHaveBeenCalled();
expect(initialState.flags).toEqual({ testFlag: true, anotherTestFlag: false });
});

test('ld client should transform keys to camel case if react options object is absent', () => {
const user: LDUser = { key: 'yus', name: 'yus ng' };
const options: LDOptions = {
bootstrap: {
'test-flag': true,
'another-test-flag': false,
},
};
const props: ProviderConfig = { clientSideID, user, options };
const LaunchDarklyApp = (
<LDProvider {...props}>
<App />
</LDProvider>
);
const component = shallow(LaunchDarklyApp, { disableLifecycleMethods: true });
const initialState = component.state() as HocState;

expect(mockInitLDClient).not.toHaveBeenCalled();
expect(initialState.flags).toEqual({ testFlag: true, anotherTestFlag: false });
});

test('state.flags should be initialised to empty when bootstrapping from localStorage', () => {
const user: LDUser = { key: 'yus', name: 'yus ng' };
const options: LDOptions = {
bootstrap: 'localStorage',
};
const props: ProviderConfig = { clientSideID, user, options };
const LaunchDarklyApp = (
<LDProvider {...props}>
<App />
</LDProvider>
);
const component = shallow(LaunchDarklyApp, { disableLifecycleMethods: true });
const initialState = component.state() as HocState;

expect(mockInitLDClient).not.toHaveBeenCalled();
expect(initialState.flags).toEqual({});
});

test('ld client is initialised correctly with target flags', async () => {
mockInitLDClient.mockImplementation(() => ({
flags: { devTestFlag: true, launchDoggly: true },
ldClient: mockLDClient,
}));
const user: LDUser = { key: 'yus', name: 'yus ng' };
const options: LDOptions = { bootstrap: {} };
const flags = { 'dev-test-flag': false, 'launch-doggly': false };
const props: ProviderConfig = { clientSideID, user, options, flags };
const LaunchDarklyApp = (
<LDProvider {...props}>
<App />
</LDProvider>
);
const instance = create(LaunchDarklyApp).root.findByType(LDProvider).instance as EnhancedComponent;
instance.setState = jest.fn();

await instance.componentDidMount();

expect(mockInitLDClient).toHaveBeenCalledWith(clientSideID, user, defaultReactOptions, options, flags);
expect(instance.setState).toHaveBeenCalledWith({
flags: { devTestFlag: true, launchDoggly: true },
ldClient: mockLDClient,
});
});

test('flags and ldClient are saved in state on mount', async () => {
const props: ProviderConfig = { clientSideID };
const LaunchDarklyApp = (
<LDProvider {...props}>
<App />
</LDProvider>
);
const instance = create(LaunchDarklyApp).root.findByType(LDProvider).instance as EnhancedComponent;
instance.setState = jest.fn();

await instance.componentDidMount();
expect(instance.setState).toHaveBeenCalledWith({ flags: mockFlags, ldClient: mockLDClient });
});

test('subscribeToChanges is called on mount', async () => {
const props: ProviderConfig = { clientSideID };
const LaunchDarklyApp = (
<LDProvider {...props}>
<App />
</LDProvider>
);
const instance = create(LaunchDarklyApp).root.findByType(LDProvider).instance as EnhancedComponent;
instance.subscribeToChanges = jest.fn();

await instance.componentDidMount();
expect(instance.subscribeToChanges).toHaveBeenCalled();
});

test('subscribe to changes with camelCase', async () => {
mockLDClient.on.mockImplementation((e: string, cb: (c: LDFlagChangeset) => void) => {
cb({ 'test-flag': { current: false, previous: true } });
});
const props: ProviderConfig = { clientSideID };
const LaunchDarklyApp = (
<LDProvider {...props}>
<App />
</LDProvider>
);
const instance = create(LaunchDarklyApp).root.findByType(LDProvider).instance as EnhancedComponent;
const mockSetState = jest.spyOn(instance, 'setState');

await instance.componentDidMount();
const callback = mockSetState.mock.calls[1][0] as (flags: LDFlagSet) => LDFlagSet;
const newState = callback({ flags: mockFlags });

expect(mockLDClient.on).toHaveBeenCalledWith('change', expect.any(Function));
expect(newState).toEqual({ flags: { anotherTestFlag: true, testFlag: false } });
});

test('subscribe to changes with kebab-case', async () => {
mockLDClient.on.mockImplementation((e: string, cb: (c: LDFlagChangeset) => void) => {
cb({ 'another-test-flag': { current: false, previous: true }, 'test-flag': { current: false, previous: true } });
});
const props: ProviderConfig = { clientSideID, reactOptions: { useCamelCaseFlagKeys: false } };
const LaunchDarklyApp = (
<LDProvider {...props}>
<App />
</LDProvider>
);
const instance = create(LaunchDarklyApp).root.findByType(LDProvider).instance as EnhancedComponent;
const mockSetState = jest.spyOn(instance, 'setState');

await instance.componentDidMount();
const callback = mockSetState.mock.calls[1][0] as (flags: LDFlagSet) => LDFlagSet;
const newState = callback({});

expect(mockLDClient.on).toHaveBeenCalledWith('change', expect.any(Function));
expect(newState).toEqual({ flags: { 'another-test-flag': false, 'test-flag': false } });
});
});
79 changes: 79 additions & 0 deletions src/provider.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import * as React from 'react';
import camelCase from 'lodash.camelcase';
import { LDClient, LDFlagSet, LDFlagChangeset } from 'launchdarkly-js-client-sdk';
import { EnhancedComponent, ProviderConfig, defaultReactOptions } from './types';
import { Provider, LDContext as HocState } from './context';
import initLDClient from './initLDClient';
import { camelCaseKeys } from './utils';

/**
* The `LDProvider` is a component which accepts a config object which is used to
* initialize `launchdarkly-js-client-sdk`.
*
* This Provider does three things:
* - It initializes the ldClient instance by calling `launchdarkly-js-client-sdk` initialize on `componentDidMount`
* - It saves all flags and the ldClient instance in the context API
* - It subscribes to flag changes and propagate them through the context API
*
* Because the `launchdarkly-js-client-sdk` in only initialized on `componentDidMount`, your flags and the
* ldClient are only available after your app has mounted. This can result in a flicker due to flag changes at
* startup time.
*
* This component can be used as a standalone provider. However, be mindful to only include the component once
* within your application. This provider is used inside the `withLDProviderHOC` and can be used instead to initialize
* the `launchdarkly-js-client-sdk`. For async initialization, check out the `asyncWithLDProvider` function
*/
class LDProvider extends React.Component<ProviderConfig, HocState> implements EnhancedComponent {
readonly state: Readonly<HocState>;

constructor(props: ProviderConfig) {
super(props);

const { options, reactOptions } = props;

this.state = {
flags: {},
ldClient: undefined,
};

if (options) {
const { bootstrap } = options;
if (bootstrap && bootstrap !== 'localStorage') {
const { useCamelCaseFlagKeys = defaultReactOptions.useCamelCaseFlagKeys } = reactOptions || {};
Copy link
Contributor

Choose a reason for hiding this comment

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

It seems the logic to generate react options based on the default and user values is being used multiple times in this component. Perhaps we can optimize this to a single function which can be re-used, thus removing duplication:

// new function to generate react options which can be re-used
getReactOptions = () => ({ ...defaultReactOptions, ...this.props.reactOptions });

// then on lines 42, 58 and 68, the usage becomes
const { useCamelCaseFlagKeys } = this.getReactOptions();

You can also safely remove the destructured prop reactOptions on line 32 above if you do this since it is no longer used.

const flags = useCamelCaseFlagKeys ? camelCaseKeys(bootstrap) : bootstrap;
this.state = {
flags,
ldClient: undefined,
};
}
}
}

subscribeToChanges = (ldClient: LDClient) => {
ldClient.on('change', (changes: LDFlagChangeset) => {
const flattened: LDFlagSet = {};
const { reactOptions } = this.props;
for (const key in changes) {
// tslint:disable-next-line:no-unsafe-any
const { useCamelCaseFlagKeys = defaultReactOptions.useCamelCaseFlagKeys } = reactOptions || {};
const flagKey = useCamelCaseFlagKeys ? camelCase(key) : key;
flattened[flagKey] = changes[key].current;
}
this.setState(({ flags }) => ({ flags: { ...flags, ...flattened } }));
});
};

async componentDidMount() {
const { clientSideID, user, flags, reactOptions: userReactOptions, options } = this.props;
const reactOptions = { ...defaultReactOptions, ...userReactOptions };
const { flags: fetchedFlags, ldClient } = await initLDClient(clientSideID, user, reactOptions, options, flags);
this.setState({ flags: fetchedFlags, ldClient });
this.subscribeToChanges(ldClient);
}

render() {
return <Provider value={this.state}>{this.props.children}</Provider>;
}
}

export default LDProvider;
1 change: 1 addition & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { ProviderConfig } from './types';
import { LDClient, LDFlagSet, LDOptions, LDUser } from 'launchdarkly-js-client-sdk';
import * as React from 'react';

Expand Down
Loading