Skip to content

Commit cc86eab

Browse files
authored
feat(react-native): no storage fallback to in-memory map (#1281)
This PR will change the no storage fallback from NOOP to in-memory storage. SDK-1856 <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Medium Risk** > Changes the React Native SDK storage fallback behavior when `@react-native-async-storage/async-storage` cannot be required, which can affect flag/key/context caching behavior in environments missing the native dependency. > > **Overview** > When `@react-native-async-storage/async-storage` is unavailable, the React Native SDK now falls back to a per-instance in-memory `Map` implementation instead of a no-op storage, providing *session-only* persistence for cached flags, generated keys, and context data. > > Adds a focused Jest test suite that forces the `require` to fail and verifies the warning log plus basic `getItem`/`setItem`/`removeItem` behavior and isolation between fallback instances. > > <sup>Reviewed by [Cursor Bugbot](https://cursor.com/bugbot) for commit 77106ed. Bugbot is set up for automated code reviews on this repo. Configure [here](https://www.cursor.com/dashboard/bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent dd28963 commit cc86eab

2 files changed

Lines changed: 89 additions & 5 deletions

File tree

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import type { LDLogger } from '@launchdarkly/js-client-sdk-common';
2+
3+
import getAsyncStorage from '../../src/platform/ConditionalAsyncStorage';
4+
5+
// Do NOT use the global jest setup mock for async-storage in this file.
6+
// We need to test what happens when the require fails.
7+
jest.mock('@react-native-async-storage/async-storage', () => {
8+
throw new Error('Cannot find module @react-native-async-storage/async-storage');
9+
});
10+
11+
describe('ConditionalAsyncStorage in-memory fallback', () => {
12+
let logger: LDLogger;
13+
let storage: any;
14+
15+
beforeEach(() => {
16+
logger = {
17+
error: jest.fn(),
18+
warn: jest.fn(),
19+
info: jest.fn(),
20+
debug: jest.fn(),
21+
};
22+
storage = getAsyncStorage(logger);
23+
});
24+
25+
it('logs a warning when AsyncStorage is unavailable', () => {
26+
expect(logger.warn).toHaveBeenCalledWith(
27+
expect.stringContaining('AsyncStorage is not available'),
28+
);
29+
expect(logger.warn).toHaveBeenCalledWith(
30+
expect.stringContaining('in-memory storage as a fallback'),
31+
);
32+
});
33+
34+
it('returns null for keys that have not been set', async () => {
35+
const value = await storage.getItem('nonexistent');
36+
expect(value).toBeNull();
37+
});
38+
39+
it('stores and retrieves values within the session', async () => {
40+
await storage.setItem('flag-key', '{"flagA":true}');
41+
const value = await storage.getItem('flag-key');
42+
expect(value).toBe('{"flagA":true}');
43+
});
44+
45+
it('overwrites existing values', async () => {
46+
await storage.setItem('key', 'first');
47+
await storage.setItem('key', 'second');
48+
const value = await storage.getItem('key');
49+
expect(value).toBe('second');
50+
});
51+
52+
it('removes values', async () => {
53+
await storage.setItem('key', 'value');
54+
await storage.removeItem('key');
55+
const value = await storage.getItem('key');
56+
expect(value).toBeNull();
57+
});
58+
59+
it('removing a nonexistent key does not throw', async () => {
60+
await expect(storage.removeItem('nonexistent')).resolves.toBeUndefined();
61+
});
62+
63+
it('isolates storage between separate fallback instances', async () => {
64+
const otherLogger: LDLogger = {
65+
error: jest.fn(),
66+
warn: jest.fn(),
67+
info: jest.fn(),
68+
debug: jest.fn(),
69+
};
70+
const otherStorage = getAsyncStorage(otherLogger);
71+
72+
await storage.setItem('key', 'from-first');
73+
await expect(otherStorage.getItem('key')).resolves.toBeNull();
74+
});
75+
});

packages/sdk/react-native/src/platform/ConditionalAsyncStorage.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -21,14 +21,23 @@ export default function getAsyncStorage(logger: LDLogger): any {
2121
try {
2222
return require('@react-native-async-storage/async-storage').default;
2323
} catch (e) {
24-
// Use a mock if async-storage is unavailable
24+
// Use an in-memory fallback if async-storage is unavailable.
25+
// This preserves session-level persistence (flag caching, generated keys,
26+
// context index) but data will not survive app restarts.
2527
logger.warn(
26-
'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.',
28+
'AsyncStorage is not available. Using in-memory storage as a fallback - cached flags, generated keys, and context data will not persist across app restarts. Please see https://launchdarkly.github.io/js-core/packages/sdk/react-native/docs/interfaces/LDOptions.html#storage for more information.',
2729
);
30+
const memoryStore = new Map<string, string>();
2831
return {
29-
getItem: (_key: string) => Promise.resolve(null),
30-
setItem: (_key: string, _value: string) => Promise.resolve(),
31-
removeItem: (_key: string) => Promise.resolve(),
32+
getItem: (key: string) => Promise.resolve(memoryStore.get(key) ?? null),
33+
setItem: (key: string, value: string) => {
34+
memoryStore.set(key, value);
35+
return Promise.resolve();
36+
},
37+
removeItem: (key: string) => {
38+
memoryStore.delete(key);
39+
return Promise.resolve();
40+
},
3241
};
3342
}
3443
}

0 commit comments

Comments
 (0)