Skip to content

Commit

Permalink
feat: custom storage option for React Native SDK (#539)
Browse files Browse the repository at this point in the history
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]>
  • Loading branch information
oblador and kinyoklion authored Aug 27, 2024
1 parent e15c7e9 commit 115bd82
Show file tree
Hide file tree
Showing 7 changed files with 147 additions and 9 deletions.
70 changes: 70 additions & 0 deletions packages/sdk/react-native/src/RNOptions.ts
Original file line number Diff line number Diff line change
@@ -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<string | null>;

/**
* 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<void>;

/**
* 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<void>;
}

export interface RNSpecificOptions {
/**
* Some platforms (windows, web, mac, linux) can continue executing code
Expand All @@ -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 {}
31 changes: 31 additions & 0 deletions packages/sdk/react-native/src/ReactNativeLDClient.storage.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
5 changes: 3 additions & 2 deletions packages/sdk/react-native/src/ReactNativeLDClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
Expand All @@ -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,
Expand Down
4 changes: 2 additions & 2 deletions packages/sdk/react-native/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
35 changes: 34 additions & 1 deletion packages/sdk/react-native/src/options.test.ts
Original file line number Diff line number Diff line change
@@ -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 = {
Expand All @@ -10,11 +11,24 @@ it('logs no warnings when all configuration is valid', () => {
error: jest.fn(),
};

const storage: RNStorage = {
get(_key: string): Promise<string | null> {
throw new Error('Function not implemented.');
},
set(_key: string, _value: string): Promise<void> {
throw new Error('Function not implemented.');
},
clear(_key: string): Promise<void> {
throw new Error('Function not implemented.');
},
};

validateOptions(
{
runInBackground: true,
automaticBackgroundHandling: true,
automaticNetworkHandling: true,
storage,
},
logger,
);
Expand All @@ -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',
);
Expand All @@ -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', () => {
Expand All @@ -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();
Expand All @@ -83,11 +103,24 @@ it('filters to base options', () => {
warn: jest.fn(),
error: jest.fn(),
};
const storage: RNStorage = {
get(_key: string): Promise<string | null> {
throw new Error('Function not implemented.');
},
set(_key: string, _value: string): Promise<void> {
throw new Error('Function not implemented.');
},
clear(_key: string): Promise<void> {
throw new Error('Function not implemented.');
},
};

const opts = {
debug: false,
runInBackground: true,
automaticBackgroundHandling: true,
automaticNetworkHandling: true,
storage,
};

const baseOpts = filterToBaseOptions(opts);
Expand Down
5 changes: 4 additions & 1 deletion packages/sdk/react-native/src/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
6 changes: 3 additions & 3 deletions packages/sdk/react-native/src/platform/index.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
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';
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;

0 comments on commit 115bd82

Please sign in to comment.