Skip to content

Commit 115bd82

Browse files
obladorkinyoklion
andauthored
feat: custom storage option for React Native SDK (#539)
This PR fixes #436 by implementing a custom storage option to the React Native package. The main motivation is to get rid of the obsolete async storage package, but we also found that having multiple clients as [recommended by the official docs](https://docs.launchdarkly.com/sdk/features/multiple-environments#react-native) when migrating away from `secondaryMobileKeys` in v9 caused them to overwrite each other's storage and therefore effectively disabling the storage/cache altogether. --------- Co-authored-by: Ryan Lamb <[email protected]>
1 parent e15c7e9 commit 115bd82

File tree

7 files changed

+147
-9
lines changed

7 files changed

+147
-9
lines changed

packages/sdk/react-native/src/RNOptions.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,63 @@
11
import { LDOptions } from '@launchdarkly/js-client-sdk-common';
22

3+
/**
4+
* Interface for providing custom storage implementations for react Native.
5+
*
6+
* This interface should only be used when customizing the storage mechanism
7+
* used by the SDK. Typical usage of the SDK does not require implementing
8+
* this interface.
9+
*
10+
* Implementations may not throw exceptions.
11+
*
12+
* The SDK assumes that the persistence is only being used by a single instance
13+
* of the SDK per SDK key (two different SDK instances, with 2 different SDK
14+
* keys could use the same persistence instance).
15+
*
16+
* The SDK, with correct usage, will not have overlapping writes to the same
17+
* key.
18+
*
19+
* This interface does not depend on the ability to list the contents of the
20+
* store or namespaces. This is to maintain the simplicity of implementing a
21+
* key-value store on many platforms.
22+
*/
23+
export interface RNStorage {
24+
/**
25+
* Implementation Note: This is the same as the platform storage interface.
26+
* The implementation is duplicated to avoid exposing the internal platform
27+
* details from implementors. This allows for us to modify the internal
28+
* interface without breaking external implementations.
29+
*/
30+
31+
/**
32+
* Get a value from the storage.
33+
*
34+
* @param key The key to get a value for.
35+
* @returns A promise which resolves to the value for the specified key, or
36+
* null if there is no value for the key.
37+
*/
38+
get: (key: string) => Promise<string | null>;
39+
40+
/**
41+
* Set the given key to the specified value.
42+
*
43+
* @param key The key to set a value for.
44+
* @param value The value to set for the key.
45+
* @returns A promise that resolves after the operation completes.
46+
*/
47+
set: (key: string, value: string) => Promise<void>;
48+
49+
/**
50+
* Clear the value associated with a given key.
51+
*
52+
* After clearing a key subsequent calls to the get function should return
53+
* null for that key.
54+
*
55+
* @param key The key to clear the value for.
56+
* @returns A promise that resolves after that operation completes.
57+
*/
58+
clear: (key: string) => Promise<void>;
59+
}
60+
361
export interface RNSpecificOptions {
462
/**
563
* Some platforms (windows, web, mac, linux) can continue executing code
@@ -25,6 +83,18 @@ export interface RNSpecificOptions {
2583
* Defaults to true.
2684
*/
2785
readonly automaticBackgroundHandling?: boolean;
86+
87+
/**
88+
* Custom storage implementation.
89+
*
90+
* Typical SDK usage will not involve using customized storage.
91+
*
92+
* Storage is used used for caching flag values for context as well as persisting generated
93+
* identifiers. Storage could be used for additional features in the future.
94+
*
95+
* Defaults to @react-native-async-storage/async-storage.
96+
*/
97+
readonly storage?: RNStorage;
2898
}
2999

30100
export default interface RNOptions extends LDOptions, RNSpecificOptions {}
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import { AutoEnvAttributes, LDLogger } from '@launchdarkly/js-client-sdk-common';
2+
3+
import ReactNativeLDClient from './ReactNativeLDClient';
4+
5+
it('uses custom storage', async () => {
6+
// This test just validates that the custom storage instance is being called.
7+
// Other tests validate how the SDK interacts with storage generally.
8+
const logger: LDLogger = {
9+
error: jest.fn(),
10+
warn: jest.fn(),
11+
info: jest.fn(),
12+
debug: jest.fn(),
13+
};
14+
const myStorage = {
15+
get: jest.fn(),
16+
set: jest.fn(),
17+
clear: jest.fn(),
18+
};
19+
const client = new ReactNativeLDClient('mobile-key', AutoEnvAttributes.Enabled, {
20+
sendEvents: false,
21+
initialConnectionMode: 'offline',
22+
logger,
23+
storage: myStorage,
24+
});
25+
26+
await client.identify({ key: 'potato', kind: 'user' }, { timeout: 15 });
27+
expect(myStorage.get).toHaveBeenCalled();
28+
expect(myStorage.clear).not.toHaveBeenCalled();
29+
// Ensure the base client is not emitting a warning for this.
30+
expect(logger.warn).not.toHaveBeenCalled();
31+
});

packages/sdk/react-native/src/ReactNativeLDClient.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -56,10 +56,12 @@ export default class ReactNativeLDClient extends LDClientImpl {
5656
highTimeoutThreshold: 15,
5757
};
5858

59+
const validatedRnOptions = validateOptions(options, logger);
60+
5961
super(
6062
sdkKey,
6163
autoEnvAttributes,
62-
createPlatform(logger),
64+
createPlatform(logger, validatedRnOptions.storage),
6365
{ ...filterToBaseOptions(options), logger },
6466
internalOptions,
6567
);
@@ -78,7 +80,6 @@ export default class ReactNativeLDClient extends LDClientImpl {
7880
},
7981
};
8082

81-
const validatedRnOptions = validateOptions(options, logger);
8283
const initialConnectionMode = options.initialConnectionMode ?? 'streaming';
8384
this.connectionManager = new ConnectionManager(
8485
logger,

packages/sdk/react-native/src/index.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,10 @@
66
* @packageDocumentation
77
*/
88
import ReactNativeLDClient from './ReactNativeLDClient';
9-
import RNOptions from './RNOptions';
9+
import RNOptions, { RNStorage } from './RNOptions';
1010

1111
export * from '@launchdarkly/js-client-sdk-common';
1212

1313
export * from './hooks';
1414
export * from './provider';
15-
export { ReactNativeLDClient, RNOptions as LDOptions };
15+
export { ReactNativeLDClient, RNOptions as LDOptions, RNStorage };

packages/sdk/react-native/src/options.test.ts

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { LDLogger } from '@launchdarkly/js-client-sdk-common';
22

33
import validateOptions, { filterToBaseOptions } from './options';
4+
import { RNStorage } from './RNOptions';
45

56
it('logs no warnings when all configuration is valid', () => {
67
const logger: LDLogger = {
@@ -10,11 +11,24 @@ it('logs no warnings when all configuration is valid', () => {
1011
error: jest.fn(),
1112
};
1213

14+
const storage: RNStorage = {
15+
get(_key: string): Promise<string | null> {
16+
throw new Error('Function not implemented.');
17+
},
18+
set(_key: string, _value: string): Promise<void> {
19+
throw new Error('Function not implemented.');
20+
},
21+
clear(_key: string): Promise<void> {
22+
throw new Error('Function not implemented.');
23+
},
24+
};
25+
1326
validateOptions(
1427
{
1528
runInBackground: true,
1629
automaticBackgroundHandling: true,
1730
automaticNetworkHandling: true,
31+
storage,
1832
},
1933
logger,
2034
);
@@ -41,11 +55,13 @@ it('warns for invalid configuration', () => {
4155
automaticBackgroundHandling: 42,
4256
// @ts-ignore
4357
automaticNetworkHandling: {},
58+
// @ts-ignore
59+
storage: 'potato',
4460
},
4561
logger,
4662
);
4763

48-
expect(logger.warn).toHaveBeenCalledTimes(3);
64+
expect(logger.warn).toHaveBeenCalledTimes(4);
4965
expect(logger.warn).toHaveBeenCalledWith(
5066
'Config option "runInBackground" should be of type boolean, got string, using default value',
5167
);
@@ -55,6 +71,9 @@ it('warns for invalid configuration', () => {
5571
expect(logger.warn).toHaveBeenCalledWith(
5672
'Config option "automaticNetworkHandling" should be of type boolean, got object, using default value',
5773
);
74+
expect(logger.warn).toHaveBeenCalledWith(
75+
'Config option "storage" should be of type object, got string, using default value',
76+
);
5877
});
5978

6079
it('applies default options', () => {
@@ -69,6 +88,7 @@ it('applies default options', () => {
6988
expect(opts.runInBackground).toBe(false);
7089
expect(opts.automaticBackgroundHandling).toBe(true);
7190
expect(opts.automaticNetworkHandling).toBe(true);
91+
expect(opts.storage).toBeUndefined();
7292

7393
expect(logger.debug).not.toHaveBeenCalled();
7494
expect(logger.info).not.toHaveBeenCalled();
@@ -83,11 +103,24 @@ it('filters to base options', () => {
83103
warn: jest.fn(),
84104
error: jest.fn(),
85105
};
106+
const storage: RNStorage = {
107+
get(_key: string): Promise<string | null> {
108+
throw new Error('Function not implemented.');
109+
},
110+
set(_key: string, _value: string): Promise<void> {
111+
throw new Error('Function not implemented.');
112+
},
113+
clear(_key: string): Promise<void> {
114+
throw new Error('Function not implemented.');
115+
},
116+
};
117+
86118
const opts = {
87119
debug: false,
88120
runInBackground: true,
89121
automaticBackgroundHandling: true,
90122
automaticNetworkHandling: true,
123+
storage,
91124
};
92125

93126
const baseOpts = filterToBaseOptions(opts);

packages/sdk/react-native/src/options.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,27 @@ import {
66
TypeValidators,
77
} from '@launchdarkly/js-client-sdk-common';
88

9-
import RNOptions from './RNOptions';
9+
import RNOptions, { RNStorage } from './RNOptions';
1010

1111
export interface ValidatedOptions {
1212
runInBackground: boolean;
1313
automaticNetworkHandling: boolean;
1414
automaticBackgroundHandling: boolean;
15+
storage?: RNStorage;
1516
}
1617

1718
const optDefaults = {
1819
runInBackground: false,
1920
automaticNetworkHandling: true,
2021
automaticBackgroundHandling: true,
22+
storage: undefined,
2123
};
2224

2325
const validators: { [Property in keyof RNOptions]: TypeValidator | undefined } = {
2426
runInBackground: TypeValidators.Boolean,
2527
automaticNetworkHandling: TypeValidators.Boolean,
2628
automaticBackgroundHandling: TypeValidators.Boolean,
29+
storage: TypeValidators.Object,
2730
};
2831

2932
export function filterToBaseOptions(opts: RNOptions): LDOptions {
Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,17 @@
1-
import { LDLogger, Platform } from '@launchdarkly/js-client-sdk-common';
1+
import { LDLogger, Platform, Storage } from '@launchdarkly/js-client-sdk-common';
22

33
import PlatformCrypto from './crypto';
44
import PlatformEncoding from './PlatformEncoding';
55
import PlatformInfo from './PlatformInfo';
66
import PlatformRequests from './PlatformRequests';
77
import PlatformStorage from './PlatformStorage';
88

9-
const createPlatform = (logger: LDLogger): Platform => ({
9+
const createPlatform = (logger: LDLogger, storage?: Storage): Platform => ({
1010
crypto: new PlatformCrypto(),
1111
info: new PlatformInfo(logger),
1212
requests: new PlatformRequests(logger),
1313
encoding: new PlatformEncoding(),
14-
storage: new PlatformStorage(logger),
14+
storage: storage ?? new PlatformStorage(logger),
1515
});
1616

1717
export default createPlatform;

0 commit comments

Comments
 (0)