Skip to content

Commit fd652c3

Browse files
raju-opticlaude
andauthored
[FSSDK-13250] Support AsyncStorage v3 in React Native (#1178)
## Summary - **AsyncStorage v3 support.** v3 removed `multiGet` in favour of `getMany`, and no longer exports the `AsyncStorageStatic` type. The React Native AsyncStorage import shim now adds a `multiGet` built on `getMany` when the storage doesn't have one. It keeps the requested key order and returns `null` for missing keys. The removed type import is replaced with a local `AsyncStorageCompat` interface. v1/v2 storage is returned unchanged. - **`AsyncStorageCache` now extends `AsyncStoreWithBatchedGet`.** It previously only implemented `AsyncStore`, so `getBatchedAsync` failed its `instanceof` check and read stored events with one `getItem` per key. Its `getBatched`, and therefore `multiGet`, was never called. Failed-event retries now read all stored events in one batched call. - **Wider React Native peer ranges:** - AsyncStorage `<4.0.0` (was `<3.0.0`) - NetInfo `<13.0.0` (was `<12.0.0`) - `react-native-get-random-values` `>=1.11.0 <3.0.0` (was `^1.11.0`) - `fast-text-encoding` and `ua-parser-js` are now written as explicit `<2.0.0` ranges, allowing the same versions as before. `ua-parser-js` 2.x is licensed AGPL-3.0, so it stays excluded. - **Test fix:** a leaked `mockImplementationOnce` in `event_processor_factory.react_native.spec.ts` was making a later test construct a real `AsyncStorageCache`. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent 07777ce commit fd652c3

9 files changed

Lines changed: 289 additions & 25 deletions

File tree

‎__mocks__/@react-native-async-storage/async-storage.ts‎

Lines changed: 23 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ export default class AsyncStorage {
2525
callback?.(undefined, value);
2626
return Promise.resolve(value);
2727
}
28-
28+
2929
static setItem(
3030
key: string,
3131
value: string,
@@ -35,7 +35,7 @@ export default class AsyncStorage {
3535
callback?.(undefined);
3636
return Promise.resolve();
3737
}
38-
38+
3939
static removeItem(
4040
key: string,
4141
callback?: (error?: Error, result?: string | null) => void
@@ -47,10 +47,28 @@ export default class AsyncStorage {
4747
callback?.(undefined, value);
4848
return Promise.resolve(value);
4949
}
50-
51-
static clearStore(): Promise<void> {
50+
51+
static getAllKeys(): Promise<string[]> {
52+
return Promise.resolve(Object.keys(AsyncStorage.items));
53+
}
54+
55+
static clear(): Promise<void> {
5256
AsyncStorage.items = {};
5357
return Promise.resolve();
5458
}
55-
59+
60+
static multiGet(keys: readonly string[]): Promise<[string, string | null][]> {
61+
return Promise.resolve(
62+
keys.map((key) => [key, AsyncStorage.items[key] ?? null] as [string, string | null])
63+
);
64+
}
65+
66+
static getMany(keys: readonly string[]): Promise<Record<string, string | null>> {
67+
const record: Record<string, string | null> = {};
68+
for (const key of keys) {
69+
record[key] = AsyncStorage.items[key] ?? null;
70+
}
71+
return Promise.resolve(record);
72+
}
73+
5674
}

‎lib/event_processor/event_processor_factory.react_native.spec.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -191,6 +191,7 @@ describe('createBatchEventProcessor', () => {
191191
expect(() => createBatchEventProcessor({ eventStore })).not.toThrow();
192192

193193
isAsyncStorageAvailable = true;
194+
MockAsyncStorageCache.mockReset();
194195
});
195196

196197
it('wraps the provided eventStore in a SyncPrefixStore if a SyncCache is provided as eventStore', () => {

‎lib/utils/cache/async_storage_cache.react_native.spec.ts‎

Lines changed: 20 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616

1717
import { vi, describe, it, expect } from 'vitest';
1818
import { AsyncStorageCache } from './async_storage_cache.react_native';
19+
import { AsyncStoreWithBatchedGet } from './store';
1920
import { getDefaultAsyncStorage } from '../import.react_native/@react-native-async-storage/async-storage';
2021

2122
vi.mock('@react-native-async-storage/async-storage');
@@ -71,11 +72,28 @@ describe('AsyncStorageCache', () => {
7172
expect(await cache.getKeys()).toEqual(['key1', 'key2']);
7273
});
7374

74-
it('should return an array of values for an array of keys when getBatched is called', async () => {
75+
it('should be an AsyncStoreWithBatchedGet', () => {
76+
expect(new AsyncStorageCache()).toBeInstanceOf(AsyncStoreWithBatchedGet);
77+
});
78+
79+
it('should return an array of values for an array of keys using a single multiGet call when getBatched is called', async () => {
7580
const cache = new AsyncStorageCache();
7681
await cache.set('key1', 'value1');
7782
await cache.set('key2', 'value2');
7883

79-
expect(await cache.getBatched(['key1', 'key2'])).toEqual(['value1', 'value2']);
84+
const multiGetSpy = vi.spyOn(asyncStorage, 'multiGet');
85+
86+
expect(await cache.getBatched(['key2', 'key1'])).toEqual(['value2', 'value1']);
87+
expect(multiGetSpy).toHaveBeenCalledOnce();
88+
expect(multiGetSpy).toHaveBeenCalledWith(['key2', 'key1']);
89+
90+
multiGetSpy.mockRestore();
91+
});
92+
93+
it('should return undefined for missing keys when getBatched is called', async () => {
94+
const cache = new AsyncStorageCache();
95+
await cache.set('key1', 'value1');
96+
97+
expect(await cache.getBatched(['key1', 'missing'])).toEqual(['value1', undefined]);
8098
});
8199
});

‎lib/utils/cache/async_storage_cache.react_native.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,11 +15,11 @@
1515
*/
1616

1717
import { Maybe } from "../type";
18-
import { AsyncStore } from "./store";
18+
import { AsyncStore, AsyncStoreWithBatchedGet } from "./store";
1919
import { getDefaultAsyncStorage } from "../import.react_native/@react-native-async-storage/async-storage";
2020
import { Platform } from '../../platform_support';
2121

22-
export class AsyncStorageCache implements AsyncStore<string> {
22+
export class AsyncStorageCache extends AsyncStoreWithBatchedGet<string> implements AsyncStore<string> {
2323
public readonly operation = 'async';
2424
private asyncStorage = getDefaultAsyncStorage();
2525

@@ -46,7 +46,7 @@ export class AsyncStorageCache implements AsyncStore<string> {
4646

4747
async getBatched(keys: string[]): Promise<Maybe<string>[]> {
4848
const items = await this.asyncStorage.multiGet(keys);
49-
return items.map(([key, value]) => value ?? undefined);
49+
return items.map(([, value]) => value ?? undefined);
5050
}
5151
}
5252

‎lib/utils/cache/store.spec.ts‎

Lines changed: 78 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,8 +14,16 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { describe, it, expect } from 'vitest';
18-
import { SyncPrefixStore, AsyncPrefixStore } from './store';
17+
import { describe, it, expect, vi } from 'vitest';
18+
import {
19+
SyncPrefixStore,
20+
AsyncPrefixStore,
21+
SyncStoreWithBatchedGet,
22+
AsyncStoreWithBatchedGet,
23+
getBatchedSync,
24+
getBatchedAsync,
25+
} from './store';
26+
import { Maybe } from '../type';
1927
import { getMockSyncCache, getMockAsyncCache } from '../../tests/mock/mock_cache';
2028

2129
describe('SyncPrefixStore', () => {
@@ -288,4 +296,72 @@ describe('AsyncPrefixStore', () => {
288296
expect(values).toEqual(expect.arrayContaining(['value1', 'value2']));
289297
});
290298
});
299+
});
300+
301+
class BatchedSyncStore extends SyncStoreWithBatchedGet<string> {
302+
private data = new Map<string, string>();
303+
set(key: string, value: string): void { this.data.set(key, value); }
304+
get(key: string): Maybe<string> { return this.data.get(key); }
305+
remove(key: string): void { this.data.delete(key); }
306+
getKeys(): string[] { return [...this.data.keys()]; }
307+
getBatched(keys: string[]): Maybe<string>[] { return keys.map((key) => this.data.get(key)); }
308+
}
309+
310+
class BatchedAsyncStore extends AsyncStoreWithBatchedGet<string> {
311+
private data = new Map<string, string>();
312+
async set(key: string, value: string): Promise<void> { this.data.set(key, value); }
313+
async get(key: string): Promise<Maybe<string>> { return this.data.get(key); }
314+
async remove(key: string): Promise<void> { this.data.delete(key); }
315+
async getKeys(): Promise<string[]> { return [...this.data.keys()]; }
316+
async getBatched(keys: string[]): Promise<Maybe<string>[]> { return keys.map((key) => this.data.get(key)); }
317+
}
318+
319+
describe('getBatchedSync', () => {
320+
it('should use getBatched of a SyncStoreWithBatchedGet', () => {
321+
const store = new BatchedSyncStore();
322+
store.set('key1', 'value1');
323+
store.set('key2', 'value2');
324+
const getBatchedSpy = vi.spyOn(store, 'getBatched');
325+
const getSpy = vi.spyOn(store, 'get');
326+
327+
expect(getBatchedSync(store, ['key2', 'missing', 'key1'])).toEqual(['value2', undefined, 'value1']);
328+
expect(getBatchedSpy).toHaveBeenCalledOnce();
329+
expect(getBatchedSpy).toHaveBeenCalledWith(['key2', 'missing', 'key1']);
330+
expect(getSpy).not.toHaveBeenCalled();
331+
});
332+
333+
it('should fall back to get for each key if the store is not a SyncStoreWithBatchedGet', () => {
334+
const store = getMockSyncCache<string>();
335+
store.set('key1', 'value1');
336+
store.set('key2', 'value2');
337+
const getSpy = vi.spyOn(store, 'get');
338+
339+
expect(getBatchedSync(store, ['key2', 'missing', 'key1'])).toEqual(['value2', undefined, 'value1']);
340+
expect(getSpy).toHaveBeenCalledTimes(3);
341+
});
342+
});
343+
344+
describe('getBatchedAsync', () => {
345+
it('should use getBatched of an AsyncStoreWithBatchedGet', async () => {
346+
const store = new BatchedAsyncStore();
347+
await store.set('key1', 'value1');
348+
await store.set('key2', 'value2');
349+
const getBatchedSpy = vi.spyOn(store, 'getBatched');
350+
const getSpy = vi.spyOn(store, 'get');
351+
352+
expect(await getBatchedAsync(store, ['key2', 'missing', 'key1'])).toEqual(['value2', undefined, 'value1']);
353+
expect(getBatchedSpy).toHaveBeenCalledOnce();
354+
expect(getBatchedSpy).toHaveBeenCalledWith(['key2', 'missing', 'key1']);
355+
expect(getSpy).not.toHaveBeenCalled();
356+
});
357+
358+
it('should fall back to get for each key if the store is not an AsyncStoreWithBatchedGet', async () => {
359+
const store = getMockAsyncCache<string>();
360+
await store.set('key1', 'value1');
361+
await store.set('key2', 'value2');
362+
const getSpy = vi.spyOn(store, 'get');
363+
364+
expect(await getBatchedAsync(store, ['key2', 'missing', 'key1'])).toEqual(['value2', undefined, 'value1']);
365+
expect(getSpy).toHaveBeenCalledTimes(3);
366+
});
291367
});
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* Copyright 2025, Optimizely
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
17+
import { vi, describe, it, expect } from 'vitest';
18+
import { ensureMultiGet } from './async-storage';
19+
20+
describe('ensureMultiGet', () => {
21+
it('should preserve multiGet when already present (v1/v2)', () => {
22+
const multiGet = vi.fn();
23+
const storage = {
24+
getItem: vi.fn(),
25+
setItem: vi.fn(),
26+
removeItem: vi.fn(),
27+
getAllKeys: vi.fn(),
28+
clear: vi.fn(),
29+
multiGet,
30+
};
31+
32+
const result = ensureMultiGet(storage);
33+
34+
expect(result.multiGet).toBe(multiGet);
35+
});
36+
37+
it('should construct multiGet from getMany when multiGet is absent (v3)', async () => {
38+
const items: Record<string, string> = { a: '1', b: '2' };
39+
const storage = {
40+
getItem: vi.fn(),
41+
setItem: vi.fn(),
42+
removeItem: vi.fn(),
43+
getAllKeys: vi.fn(),
44+
clear: vi.fn(),
45+
getMany: vi.fn((keys: readonly string[]) => {
46+
const record: Record<string, string | null> = {};
47+
for (const key of keys) { record[key] = items[key] ?? null; }
48+
return Promise.resolve(record);
49+
}),
50+
};
51+
52+
const result = ensureMultiGet(storage);
53+
54+
expect(result.multiGet).toBeDefined();
55+
56+
const pairs = await result.multiGet(['b', 'missing', 'a']);
57+
expect(pairs).toEqual([
58+
['b', '2'],
59+
['missing', null],
60+
['a', '1'],
61+
]);
62+
});
63+
64+
it('should not modify the original storage when constructing multiGet (v3)', () => {
65+
const storage = {
66+
getItem: vi.fn(),
67+
setItem: vi.fn(),
68+
removeItem: vi.fn(),
69+
getAllKeys: vi.fn(),
70+
clear: vi.fn(),
71+
getMany: vi.fn(),
72+
};
73+
74+
const result = ensureMultiGet(storage);
75+
76+
expect(result).not.toBe(storage);
77+
expect('multiGet' in storage).toBe(false);
78+
expect(Object.getPrototypeOf(result)).toBe(storage);
79+
expect(result.getItem).toBe(storage.getItem);
80+
expect(result.setItem).toBe(storage.setItem);
81+
expect(result.removeItem).toBe(storage.removeItem);
82+
expect(result.getAllKeys).toBe(storage.getAllKeys);
83+
expect(result.clear).toBe(storage.clear);
84+
});
85+
86+
it('should return pairs in the requested key order regardless of getMany record order', async () => {
87+
const items: Record<string, string> = { x: 'vx', y: 'vy', z: 'vz' };
88+
const storage = {
89+
getItem: vi.fn(),
90+
setItem: vi.fn(),
91+
removeItem: vi.fn(),
92+
getAllKeys: vi.fn(),
93+
clear: vi.fn(),
94+
getMany: vi.fn((keys: readonly string[]) => {
95+
const record: Record<string, string | null> = {};
96+
const reversed = [...keys].reverse();
97+
for (const key of reversed) { record[key] = items[key] ?? null; }
98+
return Promise.resolve(record);
99+
}),
100+
};
101+
102+
const result = ensureMultiGet(storage);
103+
const pairs = await result.multiGet(['z', 'x', 'y']);
104+
105+
expect(pairs).toEqual([
106+
['z', 'vz'],
107+
['x', 'vx'],
108+
['y', 'vy'],
109+
]);
110+
});
111+
});

‎lib/utils/import.react_native/@react-native-async-storage/async-storage.ts‎

Lines changed: 43 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,15 +14,55 @@
1414
* limitations under the License.
1515
*/
1616

17-
import type { AsyncStorageStatic } from '@react-native-async-storage/async-storage'
1817
import { Platform } from '../../../platform_support';
1918

19+
export interface AsyncStorageCompat {
20+
getItem(key: string): Promise<string | null>;
21+
setItem(key: string, value: string): Promise<void>;
22+
removeItem(key: string): Promise<void>;
23+
getAllKeys(): Promise<readonly string[]>;
24+
clear(): Promise<void>;
25+
multiGet(keys: readonly string[]): Promise<readonly [string, string | null][]>;
26+
}
27+
28+
interface RawAsyncStorage {
29+
getItem(key: string): Promise<string | null>;
30+
setItem(key: string, value: string): Promise<void>;
31+
removeItem(key: string): Promise<void>;
32+
getAllKeys(): Promise<readonly string[]>;
33+
clear(): Promise<void>;
34+
multiGet?(keys: readonly string[]): Promise<readonly [string, string | null][]>;
35+
getMany?(keys: readonly string[]): Promise<Record<string, string | null>>;
36+
}
37+
2038
export const MODULE_NOT_FOUND_REACT_NATIVE_ASYNC_STORAGE = 'Module not found: @react-native-async-storage/async-storage';
2139

22-
export const getDefaultAsyncStorage = (): AsyncStorageStatic => {
40+
type RawAsyncStorageWithGetMany = RawAsyncStorage & Required<Pick<RawAsyncStorage, 'getMany'>>;
41+
42+
// Returns a wrapper with the original as its prototype, so the shared AsyncStorage object is not mutated.
43+
export const ensureMultiGet = (storage: RawAsyncStorage): AsyncStorageCompat => {
44+
if (storage.multiGet || !storage.getMany) {
45+
return storage as AsyncStorageCompat;
46+
}
47+
48+
return Object.create(storage, {
49+
multiGet: {
50+
value: async function(
51+
this: RawAsyncStorageWithGetMany,
52+
keys: readonly string[],
53+
): Promise<[string, string | null][]> {
54+
const record = await this.getMany(keys);
55+
return keys.map((key: string) => [key, record[key] ?? null] as [string, string | null]);
56+
},
57+
},
58+
});
59+
};
60+
61+
export const getDefaultAsyncStorage = (): AsyncStorageCompat => {
2362
try {
2463
// eslint-disable-next-line @typescript-eslint/no-var-requires
25-
return require('@react-native-async-storage/async-storage').default;
64+
const storage = require('@react-native-async-storage/async-storage').default;
65+
return ensureMultiGet(storage);
2666
} catch (e) {
2767
throw new Error(MODULE_NOT_FOUND_REACT_NATIVE_ASYNC_STORAGE);
2868
}

0 commit comments

Comments
 (0)