Skip to content

Commit e2243b2

Browse files
authored
Merge pull request #76 from launchdarkly/christie/sc-161963/flags-proxy-third-times-the-charm
Flags proxy - third times the charm
2 parents 0b1cee5 + 9de061f commit e2243b2

15 files changed

+328
-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,32 +33,43 @@ 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 } = await initLDClient(clientSideID, user, reactOptions, options, targetFlags);
36+
const { ldClient, flags: fetchedFlags } = 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
});
4244

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

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

60-
return <Provider value={ldData}>{children}</Provider>;
70+
const { flags, flagKeyMap } = ldData;
71+
72+
return <Provider value={{ flags, flagKeyMap, ldClient }}>{children}</Provider>;
6173
};
6274

6375
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 camelization option is off.
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.
@@ -23,7 +30,7 @@ interface LDContext {
2330
/**
2431
* @ignore
2532
*/
26-
const context = createContext<LDContext>({ flags: {}, ldClient: undefined });
33+
const context = createContext<LDContext>({ flags: {}, flagKeyMap: {} });
2734
const {
2835
/**
2936
* @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: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
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: LDFlagSet = {};
13+
const flagKeyMap: LDFlagKeyMap = {};
14+
if (!reactOptions.useCamelCaseFlagKeys) {
15+
Object.assign(flags, filteredFlags);
16+
} else {
17+
for (const rawFlag in filteredFlags) {
18+
// Exclude system keys
19+
if (rawFlag.indexOf('$') === 0) {
20+
continue;
21+
}
22+
const camelKey = camelCase(rawFlag);
23+
flags[camelKey] = filteredFlags[rawFlag];
24+
flagKeyMap[camelKey] = rawFlag;
25+
}
26+
}
27+
28+
return {
29+
flags: reactOptions.sendEventsOnFlagRead ? toFlagsProxy(ldClient, flags, flagKeyMap) : flags,
30+
flagKeyMap,
31+
};
32+
}
33+
34+
function filterFlags(flags: LDFlagSet, targetFlags?: LDFlagSet): LDFlagSet {
35+
if (targetFlags === undefined) {
36+
return flags;
37+
}
38+
39+
return Object.keys(targetFlags).reduce<LDFlagSet>((acc, key) => {
40+
if (hasFlag(flags, key)) {
41+
acc[key] = flags[key];
42+
}
43+
44+
return acc;
45+
}, {});
46+
}
47+
48+
function hasFlag(flags: LDFlagSet, flagKey: string) {
49+
return Object.prototype.hasOwnProperty.call(flags, flagKey);
50+
}
51+
52+
function toFlagsProxy(ldClient: LDClient, flags: LDFlagSet, flagKeyMap: LDFlagKeyMap): LDFlagSet {
53+
return new Proxy(flags, {
54+
// trap for reading a flag value that refreshes its value with `LDClient#variation` to trigger an evaluation event
55+
get(target, flagKey: string, receiver) {
56+
const currentValue = Reflect.get(target, flagKey, receiver);
57+
if (currentValue === undefined) {
58+
return;
59+
}
60+
const originalFlagKey = hasFlag(flagKeyMap, flagKey) ? flagKeyMap[flagKey] : flagKey;
61+
const nextValue = ldClient.variation(originalFlagKey, currentValue);
62+
Reflect.set(target, flagKey, nextValue, receiver);
63+
64+
return nextValue;
65+
},
66+
// disable all mutation functions to make proxy readonly
67+
setPrototypeOf: () => false,
68+
set: () => false,
69+
defineProperty: () => false,
70+
deleteProperty: () => false,
71+
preventExtensions: () => false,
72+
});
73+
}

0 commit comments

Comments
 (0)