Skip to content

Commit 9723a63

Browse files
authored
Merge branch 'main' into christie/sc-164279/unearth-ld-client-init-errors-in-react-sdk
2 parents 93ce84f + 08d7a59 commit 9723a63

17 files changed

+358
-199
lines changed

src/__snapshots__/provider.test.tsx.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ exports[`LDProvider render app 1`] = `
44
<Provider
55
value={
66
Object {
7+
"flagKeyMap": Object {},
78
"flags": Object {},
89
"ldClient": undefined,
910
}

src/__snapshots__/withLDProvider.test.tsx.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ exports[`withLDProvider render app 1`] = `
44
<Provider
55
value={
66
Object {
7+
"flagKeyMap": Object {},
78
"flags": Object {},
89
"ldClient": undefined,
910
}

src/asyncWithLDProvider.test.tsx

Lines changed: 18 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,16 @@ import React from 'react';
1212
import { render } from '@testing-library/react';
1313
import { LDFlagChangeset, LDOptions, LDUser } from 'launchdarkly-js-client-sdk';
1414
import initLDClient from './initLDClient';
15-
import { fetchFlags } from './utils';
16-
import { AsyncProviderConfig, defaultReactOptions, LDReactOptions } from './types';
15+
import { AsyncProviderConfig, LDReactOptions } from './types';
1716
import { Consumer } from './context';
1817
import asyncWithLDProvider from './asyncWithLDProvider';
1918

2019
const clientSideID = 'deadbeef';
2120
const user: LDUser = { key: 'yus', name: 'yus ng' };
2221
const App = () => <>My App</>;
2322
const mockInitLDClient = initLDClient as jest.Mock;
24-
const mockFetchFlags = fetchFlags as jest.Mock;
25-
const mockFlags = { testFlag: true, anotherTestFlag: true };
26-
let mockLDClient: { on: jest.Mock };
23+
const rawFlags = { 'test-flag': true, 'another-test-flag': true };
24+
let mockLDClient: { on: jest.Mock; off: jest.Mock; variation: jest.Mock };
2725

2826
const renderWithConfig = async (config: AsyncProviderConfig) => {
2927
const LDProvider = await asyncWithLDProvider(config);
@@ -43,13 +41,15 @@ describe('asyncWithLDProvider', () => {
4341
on: jest.fn((e: string, cb: () => void) => {
4442
cb();
4543
}),
44+
off: jest.fn(),
45+
// tslint:disable-next-line: no-unsafe-any
46+
variation: jest.fn((_: string, v) => v),
4647
};
4748

4849
mockInitLDClient.mockImplementation(() => ({
4950
ldClient: mockLDClient,
51+
flags: rawFlags,
5052
}));
51-
52-
mockFetchFlags.mockReturnValue(mockFlags);
5353
});
5454

5555
afterEach(() => {
@@ -72,7 +72,7 @@ describe('asyncWithLDProvider', () => {
7272
const reactOptions: LDReactOptions = { useCamelCaseFlagKeys: false };
7373
await asyncWithLDProvider({ clientSideID, user, options, reactOptions });
7474

75-
expect(mockInitLDClient).toHaveBeenCalledWith(clientSideID, user, reactOptions, options, undefined);
75+
expect(mockInitLDClient).toHaveBeenCalledWith(clientSideID, user, options, undefined);
7676
});
7777

7878
test('subscribe to changes on mount', async () => {
@@ -97,17 +97,17 @@ describe('asyncWithLDProvider', () => {
9797
});
9898

9999
test('subscribe to changes with kebab-case', async () => {
100-
mockFetchFlags.mockReturnValue({ 'another-test-flag': true, 'test-flag': true });
101100
mockInitLDClient.mockImplementation(() => ({
102101
ldClient: mockLDClient,
102+
flags: rawFlags,
103103
}));
104104
mockLDClient.on.mockImplementation((e: string, cb: (c: LDFlagChangeset) => void) => {
105105
cb({ 'another-test-flag': { current: false, previous: true }, 'test-flag': { current: false, previous: true } });
106106
});
107107
const receivedNode = await renderWithConfig({ clientSideID, reactOptions: { useCamelCaseFlagKeys: false } });
108108

109109
expect(mockLDClient.on).toHaveBeenNthCalledWith(1, 'change', expect.any(Function));
110-
expect(receivedNode).toHaveTextContent('{"another-test-flag":false,"test-flag":false}');
110+
expect(receivedNode).toHaveTextContent('{"test-flag":false,"another-test-flag":false}');
111111
});
112112

113113
test('consecutive flag changes gets stored in context correctly', async () => {
@@ -180,31 +180,31 @@ describe('asyncWithLDProvider', () => {
180180
});
181181

182182
test('ldClient is initialised correctly with target flags', async () => {
183-
mockFetchFlags.mockReturnValue({ devTestFlag: true, launchDoggly: true });
184183
mockInitLDClient.mockImplementation(() => ({
185184
ldClient: mockLDClient,
185+
flags: rawFlags,
186186
}));
187187

188188
const options: LDOptions = {};
189-
const flags = { 'dev-test-flag': false, 'launch-doggly': false };
189+
const flags = { 'test-flag': false };
190190
const receivedNode = await renderWithConfig({ clientSideID, user, options, flags });
191191

192-
expect(mockInitLDClient).toHaveBeenCalledWith(clientSideID, user, defaultReactOptions, options, flags);
193-
expect(receivedNode).toHaveTextContent('{"devTestFlag":true,"launchDoggly":true}');
192+
expect(mockInitLDClient).toHaveBeenCalledWith(clientSideID, user, options, flags);
193+
expect(receivedNode).toHaveTextContent('{"testFlag":true}');
194194
});
195195

196196
test('only updates to subscribed flags are pushed to the Provider', async () => {
197-
mockFetchFlags.mockReturnValue({ testFlag: 2 });
198197
mockInitLDClient.mockImplementation(() => ({
199198
ldClient: mockLDClient,
199+
flags: rawFlags,
200200
}));
201201
mockLDClient.on.mockImplementation((e: string, cb: (c: LDFlagChangeset) => void) => {
202-
cb({ 'test-flag': { current: 3, previous: 2 }, 'another-test-flag': { current: false, previous: true } });
202+
cb({ 'test-flag': { current: false, previous: true }, 'another-test-flag': { current: false, previous: true } });
203203
});
204204
const options: LDOptions = {};
205-
const subscribedFlags = { 'test-flag': 1 };
205+
const subscribedFlags = { 'test-flag': true };
206206
const receivedNode = await renderWithConfig({ clientSideID, user, options, flags: subscribedFlags });
207207

208-
expect(receivedNode).toHaveTextContent('{"testFlag":3}');
208+
expect(receivedNode).toHaveTextContent('{"testFlag":false}');
209209
});
210210
});

src/asyncWithLDProvider.tsx

Lines changed: 29 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import React, { useState, useEffect, ReactNode } from 'react';
2-
import { LDFlagSet, LDFlagChangeset } from 'launchdarkly-js-client-sdk';
2+
import { LDFlagChangeset, LDFlagSet } from 'launchdarkly-js-client-sdk';
33
import { AsyncProviderConfig, defaultReactOptions } from './types';
44
import { Provider } from './context';
55
import initLDClient from './initLDClient';
6-
import { camelCaseKeys, fetchFlags, getFlattenedFlagsFromChangeset } from './utils';
6+
import { getFlattenedFlagsFromChangeset } from './utils';
7+
import getFlagsProxy from './getFlagsProxy';
78

89
/**
910
* This is an async function which initializes LaunchDarkly's JS SDK (`launchdarkly-js-client-sdk`)
@@ -32,33 +33,44 @@ import { camelCaseKeys, fetchFlags, getFlattenedFlagsFromChangeset } from './uti
3233
export default async function asyncWithLDProvider(config: AsyncProviderConfig) {
3334
const { clientSideID, user, flags: targetFlags, options, reactOptions: userReactOptions } = config;
3435
const reactOptions = { ...defaultReactOptions, ...userReactOptions };
35-
const { ldClient, error } = await initLDClient(clientSideID, user, reactOptions, options, targetFlags);
36+
const { ldClient, flags: fetchedFlags, error } = await initLDClient(clientSideID, user, options, targetFlags);
3637

3738
const LDProvider = ({ children }: { children: ReactNode }) => {
3839
const [ldData, setLDData] = useState({
39-
flags: fetchFlags(ldClient, reactOptions, targetFlags),
40-
ldClient,
40+
flags: {},
41+
unproxiedFlags: {},
42+
flagKeyMap: {},
4143
error,
4244
});
4345

4446
useEffect(() => {
45-
if (options) {
46-
const { bootstrap } = options;
47-
if (bootstrap && bootstrap !== 'localStorage') {
48-
const bootstrappedFlags = reactOptions.useCamelCaseFlagKeys ? camelCaseKeys(bootstrap) : bootstrap;
49-
setLDData((prev) => ({ ...prev, flags: bootstrappedFlags }));
47+
const initialFlags =
48+
options?.bootstrap && options.bootstrap !== 'localStorage' ? options.bootstrap : fetchedFlags;
49+
setLDData({ unproxiedFlags: initialFlags, ...getFlagsProxy(ldClient, initialFlags, reactOptions, targetFlags) });
50+
51+
function onChange(changes: LDFlagChangeset) {
52+
const updates = getFlattenedFlagsFromChangeset(changes, targetFlags);
53+
if (Object.keys(updates).length > 0) {
54+
setLDData(({ unproxiedFlags }) => {
55+
const updatedUnproxiedFlags = { ...unproxiedFlags, ...updates };
56+
57+
return {
58+
unproxiedFlags: updatedUnproxiedFlags,
59+
...getFlagsProxy(ldClient, updatedUnproxiedFlags, reactOptions, targetFlags),
60+
};
61+
});
5062
}
5163
}
64+
ldClient.on('change', onChange);
5265

53-
ldClient.on('change', (changes: LDFlagChangeset) => {
54-
const flattened: LDFlagSet = getFlattenedFlagsFromChangeset(changes, targetFlags, reactOptions);
55-
if (Object.keys(flattened).length > 0) {
56-
setLDData((prev) => ({ ...prev, flags: { ...prev.flags, ...flattened } }));
57-
}
58-
});
66+
return function cleanup() {
67+
ldClient.off('change', onChange);
68+
};
5969
}, []);
6070

61-
return <Provider value={ldData}>{children}</Provider>;
71+
const { flags, flagKeyMap } = ldData;
72+
73+
return <Provider value={{ flags, flagKeyMap, ldClient }}>{children}</Provider>;
6274
};
6375

6476
return LDProvider;

src/context.ts

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,23 @@
11
import { createContext } from 'react';
22
import { LDClient, LDFlagSet } from 'launchdarkly-js-client-sdk';
3+
import { LDFlagKeyMap } from './types';
34

45
/**
56
* The LaunchDarkly context stored in the Provider state and passed to consumers.
67
*/
78
interface LDContext {
89
/**
9-
* Contains all flags from LaunchDarkly. This object will always exist but will be empty {} initially
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
1012
* until flags are fetched from the LaunchDarkly servers.
1113
*/
1214
flags: LDFlagSet;
1315

16+
/**
17+
* Map of camelized flag keys to their original unmodified form. Empty if useCamelCaseFlagKeys option is false.
18+
*/
19+
flagKeyMap: LDFlagKeyMap;
20+
1421
/**
1522
* An instance of `LDClient` from the LaunchDarkly JS SDK (`launchdarkly-js-client-sdk`).
1623
* This will be be undefined initially until initialization is complete.
@@ -28,7 +35,7 @@ interface LDContext {
2835
/**
2936
* @ignore
3037
*/
31-
const context = createContext<LDContext>({ flags: {}, ldClient: undefined });
38+
const context = createContext<LDContext>({ flags: {}, flagKeyMap: {}, ldClient: undefined });
3239
const {
3340
/**
3441
* @ignore

src/getFlagsProxy.test.ts

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
import { LDClient, LDFlagSet } from 'launchdarkly-js-client-sdk';
2+
import getFlagsProxy from './getFlagsProxy';
3+
import { defaultReactOptions } from './types';
4+
5+
// tslint:disable-next-line: no-unsafe-any
6+
const variation = jest.fn((k: string): string | undefined => rawFlags[k]);
7+
8+
const ldClient = ({ variation } as unknown) as LDClient;
9+
10+
const rawFlags: LDFlagSet = {
11+
'foo-bar': 'foobar',
12+
'baz-qux': 'bazqux',
13+
};
14+
15+
const camelizedFlags: LDFlagSet = {
16+
fooBar: 'foobar',
17+
bazQux: 'bazqux',
18+
};
19+
20+
beforeEach(jest.clearAllMocks);
21+
22+
test('camel cases keys', () => {
23+
const { flags } = getFlagsProxy(ldClient, rawFlags);
24+
25+
expect(flags).toEqual(camelizedFlags);
26+
});
27+
28+
test('does not camel cases keys', () => {
29+
const { flags } = getFlagsProxy(ldClient, rawFlags, { useCamelCaseFlagKeys: false });
30+
31+
expect(flags).toEqual(rawFlags);
32+
});
33+
34+
test('proxy calls variation on flag read', () => {
35+
const { flags } = getFlagsProxy(ldClient, rawFlags);
36+
37+
expect(flags.fooBar).toBe('foobar');
38+
39+
expect(variation).toHaveBeenCalledWith('foo-bar', 'foobar');
40+
});
41+
42+
test('returns flag key map', () => {
43+
const { flagKeyMap } = getFlagsProxy(ldClient, rawFlags);
44+
45+
expect(flagKeyMap).toEqual({ fooBar: 'foo-bar', bazQux: 'baz-qux' });
46+
});
47+
48+
test('filters to target flags', () => {
49+
const { flags } = getFlagsProxy(ldClient, rawFlags, defaultReactOptions, { 'foo-bar': 'mr-toot' });
50+
51+
expect(flags).toEqual({ fooBar: 'foobar' });
52+
});
53+
54+
test('does not use proxy if option is false', () => {
55+
const { flags } = getFlagsProxy(ldClient, rawFlags, { sendEventsOnFlagRead: false });
56+
57+
expect(flags['foo-bar']).toBe('foobar');
58+
59+
expect(variation).not.toHaveBeenCalled();
60+
});

src/getFlagsProxy.ts

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { LDFlagSet, LDClient } from 'launchdarkly-js-client-sdk';
2+
import camelCase from 'lodash.camelcase';
3+
import { defaultReactOptions, LDFlagKeyMap, LDReactOptions } from './types';
4+
5+
export default function getFlagsProxy(
6+
ldClient: LDClient,
7+
rawFlags: LDFlagSet,
8+
reactOptions: LDReactOptions = defaultReactOptions,
9+
targetFlags?: LDFlagSet,
10+
): { flags: LDFlagSet; flagKeyMap: LDFlagKeyMap } {
11+
const filteredFlags = filterFlags(rawFlags, targetFlags);
12+
const [flags, flagKeyMap = {}] = reactOptions.useCamelCaseFlagKeys
13+
? getCamelizedKeysAndFlagMap(filteredFlags)
14+
: [filteredFlags];
15+
16+
return {
17+
flags: reactOptions.sendEventsOnFlagRead ? toFlagsProxy(ldClient, flags, flagKeyMap) : flags,
18+
flagKeyMap,
19+
};
20+
}
21+
22+
function filterFlags(flags: LDFlagSet, targetFlags?: LDFlagSet): LDFlagSet {
23+
if (targetFlags === undefined) {
24+
return flags;
25+
}
26+
27+
return Object.keys(targetFlags).reduce<LDFlagSet>((acc, key) => {
28+
if (hasFlag(flags, key)) {
29+
acc[key] = flags[key];
30+
}
31+
32+
return acc;
33+
}, {});
34+
}
35+
36+
function getCamelizedKeysAndFlagMap(rawFlags: LDFlagSet) {
37+
const flags: LDFlagSet = {};
38+
const flagKeyMap: LDFlagKeyMap = {};
39+
for (const rawFlag in rawFlags) {
40+
// Exclude system keys
41+
if (rawFlag.indexOf('$') === 0) {
42+
continue;
43+
}
44+
const camelKey = camelCase(rawFlag);
45+
flags[camelKey] = rawFlags[rawFlag];
46+
flagKeyMap[camelKey] = rawFlag;
47+
}
48+
49+
return [flags, flagKeyMap];
50+
}
51+
52+
function hasFlag(flags: LDFlagSet, flagKey: string) {
53+
return Object.prototype.hasOwnProperty.call(flags, flagKey);
54+
}
55+
56+
function toFlagsProxy(ldClient: LDClient, flags: LDFlagSet, flagKeyMap: LDFlagKeyMap): LDFlagSet {
57+
return new Proxy(flags, {
58+
// trap for reading a flag value using `LDClient#variation` to trigger an evaluation event
59+
get(target, prop, receiver) {
60+
const currentValue = Reflect.get(target, prop, receiver);
61+
if (typeof prop === 'symbol') {
62+
return currentValue;
63+
}
64+
if (currentValue === undefined) {
65+
return;
66+
}
67+
const originalFlagKey = hasFlag(flagKeyMap, prop) ? flagKeyMap[prop] : prop;
68+
const nextValue = ldClient.variation(originalFlagKey, currentValue);
69+
70+
return nextValue;
71+
},
72+
// disable all mutation functions to make proxy readonly
73+
setPrototypeOf: () => false,
74+
set: () => false,
75+
defineProperty: () => false,
76+
deleteProperty: () => false,
77+
preventExtensions: () => false,
78+
});
79+
}

0 commit comments

Comments
 (0)