From 383db70d0a8ab02b25c085845c2de8627e97acd5 Mon Sep 17 00:00:00 2001 From: Raju Ahmed Date: Mon, 28 Sep 2026 20:58:33 +0600 Subject: [PATCH 1/3] upgrade v3 --- .../async-storage.ts | 28 ++++-- ...ent_processor_factory.react_native.spec.ts | 1 + .../async_storage_cache.react_native.spec.ts | 7 ++ .../cache/async_storage_cache.react_native.ts | 2 +- .../async-storage.spec.ts | 89 +++++++++++++++++++ .../async-storage.ts | 36 +++++++- package.json | 2 +- 7 files changed, 155 insertions(+), 10 deletions(-) create mode 100644 lib/utils/import.react_native/@react-native-async-storage/async-storage.spec.ts diff --git a/__mocks__/@react-native-async-storage/async-storage.ts b/__mocks__/@react-native-async-storage/async-storage.ts index 36d3cf85d..6154a1155 100644 --- a/__mocks__/@react-native-async-storage/async-storage.ts +++ b/__mocks__/@react-native-async-storage/async-storage.ts @@ -25,7 +25,7 @@ export default class AsyncStorage { callback?.(undefined, value); return Promise.resolve(value); } - + static setItem( key: string, value: string, @@ -35,7 +35,7 @@ export default class AsyncStorage { callback?.(undefined); return Promise.resolve(); } - + static removeItem( key: string, callback?: (error?: Error, result?: string | null) => void @@ -47,10 +47,28 @@ export default class AsyncStorage { callback?.(undefined, value); return Promise.resolve(value); } - - static clearStore(): Promise { + + static getAllKeys(): Promise { + return Promise.resolve(Object.keys(AsyncStorage.items)); + } + + static clear(): Promise { AsyncStorage.items = {}; return Promise.resolve(); } - + + static multiGet(keys: readonly string[]): Promise<[string, string | null][]> { + return Promise.resolve( + keys.map((key) => [key, AsyncStorage.items[key] ?? null] as [string, string | null]) + ); + } + + static getMany(keys: readonly string[]): Promise> { + const record: Record = {}; + for (const key of keys) { + record[key] = AsyncStorage.items[key] ?? null; + } + return Promise.resolve(record); + } + } diff --git a/lib/event_processor/event_processor_factory.react_native.spec.ts b/lib/event_processor/event_processor_factory.react_native.spec.ts index 2a4c5d170..9a6286fbe 100644 --- a/lib/event_processor/event_processor_factory.react_native.spec.ts +++ b/lib/event_processor/event_processor_factory.react_native.spec.ts @@ -191,6 +191,7 @@ describe('createBatchEventProcessor', () => { expect(() => createBatchEventProcessor({ eventStore })).not.toThrow(); isAsyncStorageAvailable = true; + MockAsyncStorageCache.mockReset(); }); it('wraps the provided eventStore in a SyncPrefixStore if a SyncCache is provided as eventStore', () => { diff --git a/lib/utils/cache/async_storage_cache.react_native.spec.ts b/lib/utils/cache/async_storage_cache.react_native.spec.ts index 1ada3e80b..741cb0257 100644 --- a/lib/utils/cache/async_storage_cache.react_native.spec.ts +++ b/lib/utils/cache/async_storage_cache.react_native.spec.ts @@ -78,4 +78,11 @@ describe('AsyncStorageCache', () => { expect(await cache.getBatched(['key1', 'key2'])).toEqual(['value1', 'value2']); }); + + it('should return undefined for missing keys when getBatched is called', async () => { + const cache = new AsyncStorageCache(); + await cache.set('key1', 'value1'); + + expect(await cache.getBatched(['key1', 'missing'])).toEqual(['value1', undefined]); + }); }); diff --git a/lib/utils/cache/async_storage_cache.react_native.ts b/lib/utils/cache/async_storage_cache.react_native.ts index b568edc0b..e06c0d9a5 100644 --- a/lib/utils/cache/async_storage_cache.react_native.ts +++ b/lib/utils/cache/async_storage_cache.react_native.ts @@ -46,7 +46,7 @@ export class AsyncStorageCache implements AsyncStore { async getBatched(keys: string[]): Promise[]> { const items = await this.asyncStorage.multiGet(keys); - return items.map(([key, value]) => value ?? undefined); + return items.map(([, value]) => value ?? undefined); } } diff --git a/lib/utils/import.react_native/@react-native-async-storage/async-storage.spec.ts b/lib/utils/import.react_native/@react-native-async-storage/async-storage.spec.ts new file mode 100644 index 000000000..85aafe951 --- /dev/null +++ b/lib/utils/import.react_native/@react-native-async-storage/async-storage.spec.ts @@ -0,0 +1,89 @@ +/** + * Copyright 2025, Optimizely + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * https://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +import { vi, describe, it, expect } from 'vitest'; +import { ensureMultiGet } from './async-storage'; + +describe('ensureMultiGet', () => { + it('should preserve multiGet when already present (v1/v2)', () => { + const multiGet = vi.fn(); + const storage = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + getAllKeys: vi.fn(), + clear: vi.fn(), + multiGet, + }; + + const result = ensureMultiGet(storage); + + expect(result.multiGet).toBe(multiGet); + }); + + it('should construct multiGet from getMany when multiGet is absent (v3)', async () => { + const items: Record = { a: '1', b: '2' }; + const storage = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + getAllKeys: vi.fn(), + clear: vi.fn(), + getMany: vi.fn((keys: readonly string[]) => { + const record: Record = {}; + for (const key of keys) { record[key] = items[key] ?? null; } + return Promise.resolve(record); + }), + }; + + const result = ensureMultiGet(storage); + + expect(result.multiGet).toBeDefined(); + + const pairs = await result.multiGet(['b', 'missing', 'a']); + expect(pairs).toEqual([ + ['b', '2'], + ['missing', null], + ['a', '1'], + ]); + }); + + it('should return pairs in the requested key order regardless of getMany record order', async () => { + const items: Record = { x: 'vx', y: 'vy', z: 'vz' }; + const storage = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + getAllKeys: vi.fn(), + clear: vi.fn(), + getMany: vi.fn((keys: readonly string[]) => { + const record: Record = {}; + const reversed = [...keys].reverse(); + for (const key of reversed) { record[key] = items[key] ?? null; } + return Promise.resolve(record); + }), + }; + + const result = ensureMultiGet(storage); + const pairs = await result.multiGet(['z', 'x', 'y']); + + expect(pairs).toEqual([ + ['z', 'vz'], + ['x', 'vx'], + ['y', 'vy'], + ]); + }); +}); diff --git a/lib/utils/import.react_native/@react-native-async-storage/async-storage.ts b/lib/utils/import.react_native/@react-native-async-storage/async-storage.ts index dcf394ae0..137ec12da 100644 --- a/lib/utils/import.react_native/@react-native-async-storage/async-storage.ts +++ b/lib/utils/import.react_native/@react-native-async-storage/async-storage.ts @@ -14,15 +14,45 @@ * limitations under the License. */ -import type { AsyncStorageStatic } from '@react-native-async-storage/async-storage' import { Platform } from '../../../platform_support'; +export interface AsyncStorageCompat { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; + removeItem(key: string): Promise; + getAllKeys(): Promise; + clear(): Promise; + multiGet(keys: readonly string[]): Promise; +} + +interface RawAsyncStorage { + getItem(key: string): Promise; + setItem(key: string, value: string): Promise; + removeItem(key: string): Promise; + getAllKeys(): Promise; + clear(): Promise; + multiGet?(keys: readonly string[]): Promise; + getMany?(keys: readonly string[]): Promise>; +} + export const MODULE_NOT_FOUND_REACT_NATIVE_ASYNC_STORAGE = 'Module not found: @react-native-async-storage/async-storage'; -export const getDefaultAsyncStorage = (): AsyncStorageStatic => { +export const ensureMultiGet = (storage: RawAsyncStorage): AsyncStorageCompat => { + if (!storage.multiGet && storage.getMany) { + const getMany = storage.getMany.bind(storage); + storage.multiGet = async (keys: readonly string[]): Promise<[string, string | null][]> => { + const record = await getMany(keys); + return keys.map((key: string) => [key, record[key] ?? null] as [string, string | null]); + }; + } + return storage as AsyncStorageCompat; +}; + +export const getDefaultAsyncStorage = (): AsyncStorageCompat => { try { // eslint-disable-next-line @typescript-eslint/no-var-requires - return require('@react-native-async-storage/async-storage').default; + const storage = require('@react-native-async-storage/async-storage').default; + return ensureMultiGet(storage); } catch (e) { throw new Error(MODULE_NOT_FOUND_REACT_NATIVE_ASYNC_STORAGE); } diff --git a/package.json b/package.json index 714dad1bb..0029c3264 100644 --- a/package.json +++ b/package.json @@ -147,7 +147,7 @@ "webpack": "^5.94.0" }, "peerDependencies": { - "@react-native-async-storage/async-storage": ">=1.0.0 <3.0.0", + "@react-native-async-storage/async-storage": ">=1.0.0 <4.0.0", "@react-native-community/netinfo": ">=5.0.0 <12.0.0", "fast-text-encoding": "^1.0.6", "react-native-get-random-values": "^1.11.0", From e0c6e9098004f553f40abd7f8fcd4779f3e1cfcf Mon Sep 17 00:00:00 2001 From: Raju Ahmed Date: Thu, 8 Oct 2026 23:54:53 +0600 Subject: [PATCH 2/3] use batched reads for AsyncStorageCache and widen RN peer ranges AsyncStorageCache now extends AsyncStoreWithBatchedGet so getBatchedAsync routes failed-event retries through a single multiGet/getMany call instead of one getItem per key. Widen netinfo to <13 and get-random-values to <3 (verified on RN 0.87), and pin fast-text-encoding/ua-parser-js below 2. Co-Authored-By: Claude Opus 5.5 --- .../async_storage_cache.react_native.spec.ts | 15 +++- .../cache/async_storage_cache.react_native.ts | 4 +- lib/utils/cache/store.spec.ts | 80 ++++++++++++++++++- package-lock.json | 10 +-- package.json | 8 +- 5 files changed, 102 insertions(+), 15 deletions(-) diff --git a/lib/utils/cache/async_storage_cache.react_native.spec.ts b/lib/utils/cache/async_storage_cache.react_native.spec.ts index 741cb0257..4a8ed0fec 100644 --- a/lib/utils/cache/async_storage_cache.react_native.spec.ts +++ b/lib/utils/cache/async_storage_cache.react_native.spec.ts @@ -16,6 +16,7 @@ import { vi, describe, it, expect } from 'vitest'; import { AsyncStorageCache } from './async_storage_cache.react_native'; +import { AsyncStoreWithBatchedGet } from './store'; import { getDefaultAsyncStorage } from '../import.react_native/@react-native-async-storage/async-storage'; vi.mock('@react-native-async-storage/async-storage'); @@ -71,12 +72,22 @@ describe('AsyncStorageCache', () => { expect(await cache.getKeys()).toEqual(['key1', 'key2']); }); - it('should return an array of values for an array of keys when getBatched is called', async () => { + it('should be an AsyncStoreWithBatchedGet', () => { + expect(new AsyncStorageCache()).toBeInstanceOf(AsyncStoreWithBatchedGet); + }); + + it('should return an array of values for an array of keys using a single multiGet call when getBatched is called', async () => { const cache = new AsyncStorageCache(); await cache.set('key1', 'value1'); await cache.set('key2', 'value2'); - expect(await cache.getBatched(['key1', 'key2'])).toEqual(['value1', 'value2']); + const multiGetSpy = vi.spyOn(asyncStorage, 'multiGet'); + + expect(await cache.getBatched(['key2', 'key1'])).toEqual(['value2', 'value1']); + expect(multiGetSpy).toHaveBeenCalledOnce(); + expect(multiGetSpy).toHaveBeenCalledWith(['key2', 'key1']); + + multiGetSpy.mockRestore(); }); it('should return undefined for missing keys when getBatched is called', async () => { diff --git a/lib/utils/cache/async_storage_cache.react_native.ts b/lib/utils/cache/async_storage_cache.react_native.ts index e06c0d9a5..956cd8745 100644 --- a/lib/utils/cache/async_storage_cache.react_native.ts +++ b/lib/utils/cache/async_storage_cache.react_native.ts @@ -15,11 +15,11 @@ */ import { Maybe } from "../type"; -import { AsyncStore } from "./store"; +import { AsyncStore, AsyncStoreWithBatchedGet } from "./store"; import { getDefaultAsyncStorage } from "../import.react_native/@react-native-async-storage/async-storage"; import { Platform } from '../../platform_support'; -export class AsyncStorageCache implements AsyncStore { +export class AsyncStorageCache extends AsyncStoreWithBatchedGet implements AsyncStore { public readonly operation = 'async'; private asyncStorage = getDefaultAsyncStorage(); diff --git a/lib/utils/cache/store.spec.ts b/lib/utils/cache/store.spec.ts index a99226844..5cb693217 100644 --- a/lib/utils/cache/store.spec.ts +++ b/lib/utils/cache/store.spec.ts @@ -14,8 +14,16 @@ * limitations under the License. */ -import { describe, it, expect } from 'vitest'; -import { SyncPrefixStore, AsyncPrefixStore } from './store'; +import { describe, it, expect, vi } from 'vitest'; +import { + SyncPrefixStore, + AsyncPrefixStore, + SyncStoreWithBatchedGet, + AsyncStoreWithBatchedGet, + getBatchedSync, + getBatchedAsync, +} from './store'; +import { Maybe } from '../type'; import { getMockSyncCache, getMockAsyncCache } from '../../tests/mock/mock_cache'; describe('SyncPrefixStore', () => { @@ -288,4 +296,72 @@ describe('AsyncPrefixStore', () => { expect(values).toEqual(expect.arrayContaining(['value1', 'value2'])); }); }); +}); + +class BatchedSyncStore extends SyncStoreWithBatchedGet { + private data = new Map(); + set(key: string, value: string): void { this.data.set(key, value); } + get(key: string): Maybe { return this.data.get(key); } + remove(key: string): void { this.data.delete(key); } + getKeys(): string[] { return [...this.data.keys()]; } + getBatched(keys: string[]): Maybe[] { return keys.map((key) => this.data.get(key)); } +} + +class BatchedAsyncStore extends AsyncStoreWithBatchedGet { + private data = new Map(); + async set(key: string, value: string): Promise { this.data.set(key, value); } + async get(key: string): Promise> { return this.data.get(key); } + async remove(key: string): Promise { this.data.delete(key); } + async getKeys(): Promise { return [...this.data.keys()]; } + async getBatched(keys: string[]): Promise[]> { return keys.map((key) => this.data.get(key)); } +} + +describe('getBatchedSync', () => { + it('should use getBatched of a SyncStoreWithBatchedGet', () => { + const store = new BatchedSyncStore(); + store.set('key1', 'value1'); + store.set('key2', 'value2'); + const getBatchedSpy = vi.spyOn(store, 'getBatched'); + const getSpy = vi.spyOn(store, 'get'); + + expect(getBatchedSync(store, ['key2', 'missing', 'key1'])).toEqual(['value2', undefined, 'value1']); + expect(getBatchedSpy).toHaveBeenCalledOnce(); + expect(getBatchedSpy).toHaveBeenCalledWith(['key2', 'missing', 'key1']); + expect(getSpy).not.toHaveBeenCalled(); + }); + + it('should fall back to get for each key if the store is not a SyncStoreWithBatchedGet', () => { + const store = getMockSyncCache(); + store.set('key1', 'value1'); + store.set('key2', 'value2'); + const getSpy = vi.spyOn(store, 'get'); + + expect(getBatchedSync(store, ['key2', 'missing', 'key1'])).toEqual(['value2', undefined, 'value1']); + expect(getSpy).toHaveBeenCalledTimes(3); + }); +}); + +describe('getBatchedAsync', () => { + it('should use getBatched of an AsyncStoreWithBatchedGet', async () => { + const store = new BatchedAsyncStore(); + await store.set('key1', 'value1'); + await store.set('key2', 'value2'); + const getBatchedSpy = vi.spyOn(store, 'getBatched'); + const getSpy = vi.spyOn(store, 'get'); + + expect(await getBatchedAsync(store, ['key2', 'missing', 'key1'])).toEqual(['value2', undefined, 'value1']); + expect(getBatchedSpy).toHaveBeenCalledOnce(); + expect(getBatchedSpy).toHaveBeenCalledWith(['key2', 'missing', 'key1']); + expect(getSpy).not.toHaveBeenCalled(); + }); + + it('should fall back to get for each key if the store is not an AsyncStoreWithBatchedGet', async () => { + const store = getMockAsyncCache(); + await store.set('key1', 'value1'); + await store.set('key2', 'value2'); + const getSpy = vi.spyOn(store, 'get'); + + expect(await getBatchedAsync(store, ['key2', 'missing', 'key1'])).toEqual(['value2', undefined, 'value1']); + expect(getSpy).toHaveBeenCalledTimes(3); + }); }); \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 7d33da914..df7474636 100644 --- a/package-lock.json +++ b/package-lock.json @@ -61,11 +61,11 @@ "node": ">=18.0.0" }, "peerDependencies": { - "@react-native-async-storage/async-storage": ">=1.0.0 <3.0.0", - "@react-native-community/netinfo": ">=5.0.0 <12.0.0", - "fast-text-encoding": "^1.0.6", - "react-native-get-random-values": "^1.11.0", - "ua-parser-js": "^1.0.38" + "@react-native-async-storage/async-storage": ">=1.0.0 <4.0.0", + "@react-native-community/netinfo": ">=5.0.0 <13.0.0", + "fast-text-encoding": ">=1.0.6 <2.0.0", + "react-native-get-random-values": ">=1.11.0 <3.0.0", + "ua-parser-js": ">=1.0.38 <2.0.0" }, "peerDependenciesMeta": { "@react-native-async-storage/async-storage": { diff --git a/package.json b/package.json index 0029c3264..6edfd1dfc 100644 --- a/package.json +++ b/package.json @@ -148,10 +148,10 @@ }, "peerDependencies": { "@react-native-async-storage/async-storage": ">=1.0.0 <4.0.0", - "@react-native-community/netinfo": ">=5.0.0 <12.0.0", - "fast-text-encoding": "^1.0.6", - "react-native-get-random-values": "^1.11.0", - "ua-parser-js": "^1.0.38" + "@react-native-community/netinfo": ">=5.0.0 <13.0.0", + "fast-text-encoding": ">=1.0.6 <2.0.0", + "react-native-get-random-values": ">=1.11.0 <3.0.0", + "ua-parser-js": ">=1.0.38 <2.0.0" }, "peerDependenciesMeta": { "@react-native-async-storage/async-storage": { From f36f6739e8dbba85e1dd99654b286251b08fd170 Mon Sep 17 00:00:00 2001 From: Raju Ahmed Date: Tue, 13 Oct 2026 20:28:48 +0600 Subject: [PATCH 3/3] wrap AsyncStorage instead of mutating it when adding multiGet The shim added multiGet directly to the shared AsyncStorage default export, which other code in the app could see. Return an Object.create wrapper with the original as prototype instead, resolving getMany via this. Co-Authored-By: Claude Opus 5.5 --- .../async-storage.spec.ts | 22 +++++++++++++++++ .../async-storage.ts | 24 +++++++++++++------ 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/lib/utils/import.react_native/@react-native-async-storage/async-storage.spec.ts b/lib/utils/import.react_native/@react-native-async-storage/async-storage.spec.ts index 85aafe951..8a77e3977 100644 --- a/lib/utils/import.react_native/@react-native-async-storage/async-storage.spec.ts +++ b/lib/utils/import.react_native/@react-native-async-storage/async-storage.spec.ts @@ -61,6 +61,28 @@ describe('ensureMultiGet', () => { ]); }); + it('should not modify the original storage when constructing multiGet (v3)', () => { + const storage = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + getAllKeys: vi.fn(), + clear: vi.fn(), + getMany: vi.fn(), + }; + + const result = ensureMultiGet(storage); + + expect(result).not.toBe(storage); + expect('multiGet' in storage).toBe(false); + expect(Object.getPrototypeOf(result)).toBe(storage); + expect(result.getItem).toBe(storage.getItem); + expect(result.setItem).toBe(storage.setItem); + expect(result.removeItem).toBe(storage.removeItem); + expect(result.getAllKeys).toBe(storage.getAllKeys); + expect(result.clear).toBe(storage.clear); + }); + it('should return pairs in the requested key order regardless of getMany record order', async () => { const items: Record = { x: 'vx', y: 'vy', z: 'vz' }; const storage = { diff --git a/lib/utils/import.react_native/@react-native-async-storage/async-storage.ts b/lib/utils/import.react_native/@react-native-async-storage/async-storage.ts index 137ec12da..83881b334 100644 --- a/lib/utils/import.react_native/@react-native-async-storage/async-storage.ts +++ b/lib/utils/import.react_native/@react-native-async-storage/async-storage.ts @@ -37,15 +37,25 @@ interface RawAsyncStorage { export const MODULE_NOT_FOUND_REACT_NATIVE_ASYNC_STORAGE = 'Module not found: @react-native-async-storage/async-storage'; +type RawAsyncStorageWithGetMany = RawAsyncStorage & Required>; + +// Returns a wrapper with the original as its prototype, so the shared AsyncStorage object is not mutated. export const ensureMultiGet = (storage: RawAsyncStorage): AsyncStorageCompat => { - if (!storage.multiGet && storage.getMany) { - const getMany = storage.getMany.bind(storage); - storage.multiGet = async (keys: readonly string[]): Promise<[string, string | null][]> => { - const record = await getMany(keys); - return keys.map((key: string) => [key, record[key] ?? null] as [string, string | null]); - }; + if (storage.multiGet || !storage.getMany) { + return storage as AsyncStorageCompat; } - return storage as AsyncStorageCompat; + + return Object.create(storage, { + multiGet: { + value: async function( + this: RawAsyncStorageWithGetMany, + keys: readonly string[], + ): Promise<[string, string | null][]> { + const record = await this.getMany(keys); + return keys.map((key: string) => [key, record[key] ?? null] as [string, string | null]); + }, + }, + }); }; export const getDefaultAsyncStorage = (): AsyncStorageCompat => {