diff --git a/packages/sdk/react-native/src/RNOptions.ts b/packages/sdk/react-native/src/RNOptions.ts index b5050296ef..c48c22e3ae 100644 --- a/packages/sdk/react-native/src/RNOptions.ts +++ b/packages/sdk/react-native/src/RNOptions.ts @@ -1,5 +1,63 @@ import { LDOptions } from '@launchdarkly/js-client-sdk-common'; +/** + * Interface for providing custom storage implementations for react Native. + * + * This interface should only be used when customizing the storage mechanism + * used by the SDK. Typical usage of the SDK does not require implementing + * this interface. + * + * Implementations may not throw exceptions. + * + * The SDK assumes that the persistence is only being used by a single instance + * of the SDK per SDK key (two different SDK instances, with 2 different SDK + * keys could use the same persistence instance). + * + * The SDK, with correct usage, will not have overlapping writes to the same + * key. + * + * This interface does not depend on the ability to list the contents of the + * store or namespaces. This is to maintain the simplicity of implementing a + * key-value store on many platforms. + */ +export interface RNStorage { + /** + * Implementation Note: This is the same as the platform storage interface. + * The implementation is duplicated to avoid exposing the internal platform + * details from implementors. This allows for us to modify the internal + * interface without breaking external implementations. + */ + + /** + * Get a value from the storage. + * + * @param key The key to get a value for. + * @returns A promise which resolves to the value for the specified key, or + * null if there is no value for the key. + */ + get: (key: string) => Promise; + + /** + * Set the given key to the specified value. + * + * @param key The key to set a value for. + * @param value The value to set for the key. + * @returns A promise that resolves after the operation completes. + */ + set: (key: string, value: string) => Promise; + + /** + * Clear the value associated with a given key. + * + * After clearing a key subsequent calls to the get function should return + * null for that key. + * + * @param key The key to clear the value for. + * @returns A promise that resolves after that operation completes. + */ + clear: (key: string) => Promise; +} + export interface RNSpecificOptions { /** * Some platforms (windows, web, mac, linux) can continue executing code @@ -25,6 +83,18 @@ export interface RNSpecificOptions { * Defaults to true. */ readonly automaticBackgroundHandling?: boolean; + + /** + * Custom storage implementation. + * + * Typical SDK usage will not involve using customized storage. + * + * Storage is used used for caching flag values for context as well as persisting generated + * identifiers. Storage could be used for additional features in the future. + * + * Defaults to @react-native-async-storage/async-storage. + */ + readonly storage?: RNStorage; } export default interface RNOptions extends LDOptions, RNSpecificOptions {} diff --git a/packages/sdk/react-native/src/ReactNativeLDClient.storage.test.ts b/packages/sdk/react-native/src/ReactNativeLDClient.storage.test.ts new file mode 100644 index 0000000000..26b6dd7c42 --- /dev/null +++ b/packages/sdk/react-native/src/ReactNativeLDClient.storage.test.ts @@ -0,0 +1,31 @@ +import { AutoEnvAttributes, LDLogger } from '@launchdarkly/js-client-sdk-common'; + +import ReactNativeLDClient from './ReactNativeLDClient'; + +it('uses custom storage', async () => { + // This test just validates that the custom storage instance is being called. + // Other tests validate how the SDK interacts with storage generally. + const logger: LDLogger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + const myStorage = { + get: jest.fn(), + set: jest.fn(), + clear: jest.fn(), + }; + const client = new ReactNativeLDClient('mobile-key', AutoEnvAttributes.Enabled, { + sendEvents: false, + initialConnectionMode: 'offline', + logger, + storage: myStorage, + }); + + await client.identify({ key: 'potato', kind: 'user' }, { timeout: 15 }); + expect(myStorage.get).toHaveBeenCalled(); + expect(myStorage.clear).not.toHaveBeenCalled(); + // Ensure the base client is not emitting a warning for this. + expect(logger.warn).not.toHaveBeenCalled(); +}); diff --git a/packages/sdk/react-native/src/ReactNativeLDClient.ts b/packages/sdk/react-native/src/ReactNativeLDClient.ts index fb55aac332..44558a5cbb 100644 --- a/packages/sdk/react-native/src/ReactNativeLDClient.ts +++ b/packages/sdk/react-native/src/ReactNativeLDClient.ts @@ -56,10 +56,12 @@ export default class ReactNativeLDClient extends LDClientImpl { highTimeoutThreshold: 15, }; + const validatedRnOptions = validateOptions(options, logger); + super( sdkKey, autoEnvAttributes, - createPlatform(logger), + createPlatform(logger, validatedRnOptions.storage), { ...filterToBaseOptions(options), logger }, internalOptions, ); @@ -78,7 +80,6 @@ export default class ReactNativeLDClient extends LDClientImpl { }, }; - const validatedRnOptions = validateOptions(options, logger); const initialConnectionMode = options.initialConnectionMode ?? 'streaming'; this.connectionManager = new ConnectionManager( logger, diff --git a/packages/sdk/react-native/src/index.ts b/packages/sdk/react-native/src/index.ts index 170a41314c..4c6dc99230 100644 --- a/packages/sdk/react-native/src/index.ts +++ b/packages/sdk/react-native/src/index.ts @@ -6,10 +6,10 @@ * @packageDocumentation */ import ReactNativeLDClient from './ReactNativeLDClient'; -import RNOptions from './RNOptions'; +import RNOptions, { RNStorage } from './RNOptions'; export * from '@launchdarkly/js-client-sdk-common'; export * from './hooks'; export * from './provider'; -export { ReactNativeLDClient, RNOptions as LDOptions }; +export { ReactNativeLDClient, RNOptions as LDOptions, RNStorage }; diff --git a/packages/sdk/react-native/src/options.test.ts b/packages/sdk/react-native/src/options.test.ts index 6de8a2e1d5..91f179a026 100644 --- a/packages/sdk/react-native/src/options.test.ts +++ b/packages/sdk/react-native/src/options.test.ts @@ -1,6 +1,7 @@ import { LDLogger } from '@launchdarkly/js-client-sdk-common'; import validateOptions, { filterToBaseOptions } from './options'; +import { RNStorage } from './RNOptions'; it('logs no warnings when all configuration is valid', () => { const logger: LDLogger = { @@ -10,11 +11,24 @@ it('logs no warnings when all configuration is valid', () => { error: jest.fn(), }; + const storage: RNStorage = { + get(_key: string): Promise { + throw new Error('Function not implemented.'); + }, + set(_key: string, _value: string): Promise { + throw new Error('Function not implemented.'); + }, + clear(_key: string): Promise { + throw new Error('Function not implemented.'); + }, + }; + validateOptions( { runInBackground: true, automaticBackgroundHandling: true, automaticNetworkHandling: true, + storage, }, logger, ); @@ -41,11 +55,13 @@ it('warns for invalid configuration', () => { automaticBackgroundHandling: 42, // @ts-ignore automaticNetworkHandling: {}, + // @ts-ignore + storage: 'potato', }, logger, ); - expect(logger.warn).toHaveBeenCalledTimes(3); + expect(logger.warn).toHaveBeenCalledTimes(4); expect(logger.warn).toHaveBeenCalledWith( 'Config option "runInBackground" should be of type boolean, got string, using default value', ); @@ -55,6 +71,9 @@ it('warns for invalid configuration', () => { expect(logger.warn).toHaveBeenCalledWith( 'Config option "automaticNetworkHandling" should be of type boolean, got object, using default value', ); + expect(logger.warn).toHaveBeenCalledWith( + 'Config option "storage" should be of type object, got string, using default value', + ); }); it('applies default options', () => { @@ -69,6 +88,7 @@ it('applies default options', () => { expect(opts.runInBackground).toBe(false); expect(opts.automaticBackgroundHandling).toBe(true); expect(opts.automaticNetworkHandling).toBe(true); + expect(opts.storage).toBeUndefined(); expect(logger.debug).not.toHaveBeenCalled(); expect(logger.info).not.toHaveBeenCalled(); @@ -83,11 +103,24 @@ it('filters to base options', () => { warn: jest.fn(), error: jest.fn(), }; + const storage: RNStorage = { + get(_key: string): Promise { + throw new Error('Function not implemented.'); + }, + set(_key: string, _value: string): Promise { + throw new Error('Function not implemented.'); + }, + clear(_key: string): Promise { + throw new Error('Function not implemented.'); + }, + }; + const opts = { debug: false, runInBackground: true, automaticBackgroundHandling: true, automaticNetworkHandling: true, + storage, }; const baseOpts = filterToBaseOptions(opts); diff --git a/packages/sdk/react-native/src/options.ts b/packages/sdk/react-native/src/options.ts index e2a07b888f..ffc2b57e31 100644 --- a/packages/sdk/react-native/src/options.ts +++ b/packages/sdk/react-native/src/options.ts @@ -6,24 +6,27 @@ import { TypeValidators, } from '@launchdarkly/js-client-sdk-common'; -import RNOptions from './RNOptions'; +import RNOptions, { RNStorage } from './RNOptions'; export interface ValidatedOptions { runInBackground: boolean; automaticNetworkHandling: boolean; automaticBackgroundHandling: boolean; + storage?: RNStorage; } const optDefaults = { runInBackground: false, automaticNetworkHandling: true, automaticBackgroundHandling: true, + storage: undefined, }; const validators: { [Property in keyof RNOptions]: TypeValidator | undefined } = { runInBackground: TypeValidators.Boolean, automaticNetworkHandling: TypeValidators.Boolean, automaticBackgroundHandling: TypeValidators.Boolean, + storage: TypeValidators.Object, }; export function filterToBaseOptions(opts: RNOptions): LDOptions { diff --git a/packages/sdk/react-native/src/platform/index.ts b/packages/sdk/react-native/src/platform/index.ts index e4b29b25d5..3502ca2840 100644 --- a/packages/sdk/react-native/src/platform/index.ts +++ b/packages/sdk/react-native/src/platform/index.ts @@ -1,4 +1,4 @@ -import { LDLogger, Platform } from '@launchdarkly/js-client-sdk-common'; +import { LDLogger, Platform, Storage } from '@launchdarkly/js-client-sdk-common'; import PlatformCrypto from './crypto'; import PlatformEncoding from './PlatformEncoding'; @@ -6,12 +6,12 @@ import PlatformInfo from './PlatformInfo'; import PlatformRequests from './PlatformRequests'; import PlatformStorage from './PlatformStorage'; -const createPlatform = (logger: LDLogger): Platform => ({ +const createPlatform = (logger: LDLogger, storage?: Storage): Platform => ({ crypto: new PlatformCrypto(), info: new PlatformInfo(logger), requests: new PlatformRequests(logger), encoding: new PlatformEncoding(), - storage: new PlatformStorage(logger), + storage: storage ?? new PlatformStorage(logger), }); export default createPlatform;