From 383db70d0a8ab02b25c085845c2de8627e97acd5 Mon Sep 17 00:00:00 2001 From: Raju Ahmed Date: Mon, 28 Sep 2026 20:58:33 +0600 Subject: [PATCH 1/2] 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/2] 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": {