From 9d5afd2294d68fe9fd035dd713932112f10b0128 Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Wed, 24 Dec 2025 13:52:09 -0500 Subject: [PATCH 1/2] refactor: added warning if async storage is not found - Added a warning message for when async storage is not found and is being mocked. - Added tests to verify behavior with custom and default storage implementations. --- .../ReactNativeLDClient.storage.test.ts | 101 +++++++++++++----- .../react-native-sse/EventSource.ts | 2 +- .../src/platform/ConditionalAsyncStorage.ts | 27 ++--- .../src/platform/PlatformStorage.ts | 14 ++- 4 files changed, 101 insertions(+), 43 deletions(-) diff --git a/packages/sdk/react-native/__tests__/ReactNativeLDClient.storage.test.ts b/packages/sdk/react-native/__tests__/ReactNativeLDClient.storage.test.ts index 53bde5c121..17f7d684b7 100644 --- a/packages/sdk/react-native/__tests__/ReactNativeLDClient.storage.test.ts +++ b/packages/sdk/react-native/__tests__/ReactNativeLDClient.storage.test.ts @@ -1,31 +1,84 @@ import { AutoEnvAttributes, LDLogger } from '@launchdarkly/js-client-sdk-common'; +import PlatformStorage from '../src/platform/PlatformStorage'; import ReactNativeLDClient from '../src/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, +jest.mock('../src/platform/PlatformStorage', () => ({ + __esModule: true, + default: jest.fn().mockImplementation((logger: LDLogger) => { + const ActualPlatformStorage = jest.requireActual('../src/platform/PlatformStorage').default; + return new ActualPlatformStorage(logger); + }), +})); + +describe('ReactNativeLDClient storage', () => { + beforeEach(() => { + (PlatformStorage as jest.MockedClass).mockClear(); + }); + + 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(); + // Ensure the default platform storage is not instantiated when custom storage is provided. + expect(PlatformStorage).not.toHaveBeenCalled(); }); - 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(); + it('uses default platform storage when no custom storage is provided', async () => { + const logger: LDLogger = { + error: jest.fn(), + warn: jest.fn(), + info: jest.fn(), + debug: jest.fn(), + }; + const client = new ReactNativeLDClient('mobile-key', AutoEnvAttributes.Enabled, { + sendEvents: false, + initialConnectionMode: 'offline', + logger, + }); + + // Verify that PlatformStorage was instantiated + expect(PlatformStorage).toHaveBeenCalledWith(logger); + expect(PlatformStorage).toHaveBeenCalledTimes(1); + + // Get the storage instance and spy on its methods + const mockResults = (PlatformStorage as jest.MockedClass).mock.results; + expect(mockResults.length).toBeGreaterThan(0); + const storageInstance = mockResults[0].value; + expect(storageInstance).toBeDefined(); + + const getSpy = jest.spyOn(storageInstance, 'get'); + const setSpy = jest.spyOn(storageInstance, 'set'); + + await client.identify({ key: 'potato', kind: 'user' }, { timeout: 15 }); + + // Verify that storage methods are being called + expect(getSpy).toHaveBeenCalled(); + expect(setSpy).toHaveBeenCalled(); + + getSpy.mockRestore(); + setSpy.mockRestore(); + }); }); diff --git a/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts b/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts index a871f65f03..89dbf83b5a 100644 --- a/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts +++ b/packages/sdk/react-native/src/fromExternal/react-native-sse/EventSource.ts @@ -5,7 +5,7 @@ * 2. added onopen, onclose, onerror, onretrying functions. * 3. modified dispatch to work with functions added in 2. * 4. replaced all for of loops with foreach - * + * * Additional changes: * 1. separated event handling to use onprogress for data changes * and onreadystatechange for status changes. This is to address diff --git a/packages/sdk/react-native/src/platform/ConditionalAsyncStorage.ts b/packages/sdk/react-native/src/platform/ConditionalAsyncStorage.ts index de3ea70916..ded54434d1 100644 --- a/packages/sdk/react-native/src/platform/ConditionalAsyncStorage.ts +++ b/packages/sdk/react-native/src/platform/ConditionalAsyncStorage.ts @@ -1,4 +1,4 @@ -/* eslint-disable import/no-mutable-exports,global-require */ +/* eslint-disable global-require */ /** * The LaunchDarkly React-Native SDK uses @@ -15,17 +15,18 @@ * https://github.com/react-native-community/cli/issues/1347 * */ -let ConditionalAsyncStorage: any; +import type { LDLogger } from '@launchdarkly/js-client-sdk-common'; -try { - ConditionalAsyncStorage = require('@react-native-async-storage/async-storage').default; -} catch (e) { - // Use a mock if async-storage is unavailable - ConditionalAsyncStorage = { - getItem: (_key: string) => Promise.resolve(null), - setItem: (_key: string, _value: string) => Promise.resolve(), - removeItem: (_key: string) => Promise.resolve(), - }; +export default function getAsyncStorage(logger: LDLogger): any { + try { + return require('@react-native-async-storage/async-storage').default; + } catch (e) { + // Use a mock if async-storage is unavailable + logger.warn('AsyncStorage is not available, using a mock implementation.'); + return { + getItem: (_key: string) => Promise.resolve(null), + setItem: (_key: string, _value: string) => Promise.resolve(), + removeItem: (_key: string) => Promise.resolve(), + }; + } } - -export default ConditionalAsyncStorage; diff --git a/packages/sdk/react-native/src/platform/PlatformStorage.ts b/packages/sdk/react-native/src/platform/PlatformStorage.ts index 9460bdf377..9f8da192be 100644 --- a/packages/sdk/react-native/src/platform/PlatformStorage.ts +++ b/packages/sdk/react-native/src/platform/PlatformStorage.ts @@ -1,16 +1,20 @@ import type { LDLogger, Storage } from '@launchdarkly/js-client-sdk-common'; -import AsyncStorage from './ConditionalAsyncStorage'; +import getAsyncStorage from './ConditionalAsyncStorage'; export default class PlatformStorage implements Storage { - constructor(private readonly _logger: LDLogger) {} + private _asyncStorage: any; + constructor(private readonly _logger: LDLogger) { + this._asyncStorage = getAsyncStorage(_logger); + } + async clear(key: string): Promise { - await AsyncStorage.removeItem(key); + await this._asyncStorage.removeItem(key); } async get(key: string): Promise { try { - const value = await AsyncStorage.getItem(key); + const value = await this._asyncStorage.getItem(key); return value ?? null; } catch (error) { this._logger.debug(`Error getting AsyncStorage key: ${key}, error: ${error}`); @@ -20,7 +24,7 @@ export default class PlatformStorage implements Storage { async set(key: string, value: string): Promise { try { - await AsyncStorage.setItem(key, value); + await this._asyncStorage.setItem(key, value); } catch (error) { this._logger.debug(`Error saving AsyncStorage key: ${key}, value: ${value}, error: ${error}`); } From db43ac2baf76769f3d4db7f24081701671ee5b8e Mon Sep 17 00:00:00 2001 From: Steven Zhang Date: Tue, 6 Jan 2026 10:33:11 -0600 Subject: [PATCH 2/2] docs: adding more details to docs for RN storage --- packages/sdk/react-native/src/RNOptions.ts | 6 +++++- .../react-native/src/platform/ConditionalAsyncStorage.ts | 4 +++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/sdk/react-native/src/RNOptions.ts b/packages/sdk/react-native/src/RNOptions.ts index 6b34e6a718..67171f1afc 100644 --- a/packages/sdk/react-native/src/RNOptions.ts +++ b/packages/sdk/react-native/src/RNOptions.ts @@ -94,7 +94,11 @@ export interface RNSpecificOptions { * 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. + * Defaults to `@react-native-async-storage/async-storage` see [their documentation](https://react-native-async-storage.github.io/2.0/) for more information. + * + * @remarks + * If there is an issue with the storage implementation, then generated keys and context caches may not be persisted. This will cause the SDK to + * generate new keys and context caches on every startup. */ readonly storage?: RNStorage; diff --git a/packages/sdk/react-native/src/platform/ConditionalAsyncStorage.ts b/packages/sdk/react-native/src/platform/ConditionalAsyncStorage.ts index ded54434d1..3b559624e9 100644 --- a/packages/sdk/react-native/src/platform/ConditionalAsyncStorage.ts +++ b/packages/sdk/react-native/src/platform/ConditionalAsyncStorage.ts @@ -22,7 +22,9 @@ export default function getAsyncStorage(logger: LDLogger): any { return require('@react-native-async-storage/async-storage').default; } catch (e) { // Use a mock if async-storage is unavailable - logger.warn('AsyncStorage is not available, using a mock implementation.'); + logger.warn( + 'AsyncStorage is not available, generated keys and context caches will not be persisted. Please see https://launchdarkly.github.io/js-core/packages/sdk/react-native/docs/interfaces/LDOptions.html#storage for more information.', + ); return { getItem: (_key: string) => Promise.resolve(null), setItem: (_key: string, _value: string) => Promise.resolve(),