-
Notifications
You must be signed in to change notification settings - Fork 70
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
Closed
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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> | ||
`; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 } }); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 || {}; | ||
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; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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:
You can also safely remove the destructured prop
reactOptions
on line 32 above if you do this since it is no longer used.