From 8f0419662a85637fa1c665e9e5cb5d2bfaf9a7bf Mon Sep 17 00:00:00 2001 From: Raju Ahmed Date: Wed, 14 Oct 2026 06:40:02 +0600 Subject: [PATCH 1/6] load react native polyfills only when missing, from separate modules fast-text-encoding and react-native-get-random-values are now only required when TextEncoder / crypto.getRandomValues are missing, so apps can drop them or load their own polyfill before the SDK. These requires and the async-storage require are moved into their own modules (imported as react_native_optional/*, shipped in dist/react_native_optional) instead of being bundled. Metro before 0.82.5 drops a missing optional dependency from a module's dependency map, which shifts every later require in that module to the wrong module. Co-Authored-By: Claude Opus 5.5 --- ...ent_processor_factory.react_native.spec.ts | 24 ++--------- lib/index.react_native.ts | 6 ++- ...onfig_manager_factory.react_native.spec.ts | 21 ++-------- .../async-storage.spec.ts | 40 ++++++++++++++++++- .../async-storage.ts | 9 ++--- .../optional/async_storage.ts | 35 ++++++++++++++++ .../optional/fast_text_encoding.ts | 39 ++++++++++++++++++ .../optional/get_random_values.ts | 34 ++++++++++++++++ rollup.config.mjs | 36 ++++++++++++++++- tsconfig.json | 3 ++ vitest.config.mts | 1 + 11 files changed, 200 insertions(+), 48 deletions(-) create mode 100644 lib/utils/import.react_native/optional/async_storage.ts create mode 100644 lib/utils/import.react_native/optional/fast_text_encoding.ts create mode 100644 lib/utils/import.react_native/optional/get_random_values.ts 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 9a6286fbe..0629ed48d 100644 --- a/lib/event_processor/event_processor_factory.react_native.spec.ts +++ b/lib/event_processor/event_processor_factory.react_native.spec.ts @@ -55,26 +55,10 @@ vi.mock('@react-native-community/netinfo', () => { }); let isAsyncStorageAvailable = true; -// Mock Node.js module loader to simulate missing @react-native-async-storage/async-storage -vi.hoisted(() => { - mockRequireNetInfo(); -}); - -function mockRequireNetInfo() { - // Dynamically import module synchronously in Node environment - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { Module } = require('module'); - const M: any = Module; - - M._load_original = M._load; - M._load = (uri: string, parent: string) => { - if (uri === '@react-native-async-storage/async-storage') { - if (isAsyncStorageAvailable) return {}; - throw new Error("Module not found: @react-native-async-storage/async-storage"); - } - return M._load_original(uri, parent); - }; -} +// Simulate a missing @react-native-async-storage/async-storage +vi.mock('react_native_optional/async_storage', () => ({ + loadAsyncStorageModule: () => (isAsyncStorageAvailable ? { default: {} } : undefined), +})); import { createForwardingEventProcessor, createBatchEventProcessor } from './event_processor_factory.react_native'; import defaultEventDispatcher from './event_dispatcher/default_dispatcher.browser'; diff --git a/lib/index.react_native.ts b/lib/index.react_native.ts index 6914a5f97..08fba7452 100644 --- a/lib/index.react_native.ts +++ b/lib/index.react_native.ts @@ -13,8 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -import 'fast-text-encoding'; -import 'react-native-get-random-values'; +// Each polyfill is only loaded when its global is missing, so apps can provide their own polyfill +// instead by loading it before the SDK is imported. +import 'react_native_optional/fast_text_encoding'; +import 'react_native_optional/get_random_values'; import { Client, Config } from './shared_types'; import { getOptimizelyInstance, OptimizelyFactoryConfig } from './client_factory'; diff --git a/lib/project_config/config_manager_factory.react_native.spec.ts b/lib/project_config/config_manager_factory.react_native.spec.ts index e73440ac0..195b3683d 100644 --- a/lib/project_config/config_manager_factory.react_native.spec.ts +++ b/lib/project_config/config_manager_factory.react_native.spec.ts @@ -16,26 +16,11 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; -vi.hoisted(async () => { - mockRequireAsyncStorage(); -}); - let isAsyncStorageAvailable = true; -function mockRequireAsyncStorage() { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const { Module } = require('module'); - const M: any = Module; - - M._load_original = M._load; - M._load = (uri: string, parent: string) => { - if (uri === '@react-native-async-storage/async-storage') { - if (isAsyncStorageAvailable) return { default: {} }; - throw new Error("Module not found: @react-native-async-storage/async-storage"); - } - return M._load_original(uri, parent); - }; -} +vi.mock('react_native_optional/async_storage', () => ({ + loadAsyncStorageModule: () => (isAsyncStorageAvailable ? { default: {} } : undefined), +})); vi.mock('./config_manager_factory', () => { return { 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 8a77e3977..a675dd1ae 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 @@ -15,7 +15,45 @@ */ import { vi, describe, it, expect } from 'vitest'; -import { ensureMultiGet } from './async-storage'; + +vi.mock('react_native_optional/async_storage', () => ({ + loadAsyncStorageModule: vi.fn(), +})); + +import { ensureMultiGet, getDefaultAsyncStorage, MODULE_NOT_FOUND_REACT_NATIVE_ASYNC_STORAGE } from './async-storage'; +import { loadAsyncStorageModule } from 'react_native_optional/async_storage'; + +describe('getDefaultAsyncStorage', () => { + it('should throw module not found error when async storage is not installed', () => { + vi.mocked(loadAsyncStorageModule).mockReturnValueOnce(undefined); + + expect(() => getDefaultAsyncStorage()).toThrow(MODULE_NOT_FOUND_REACT_NATIVE_ASYNC_STORAGE); + }); + + it('should throw module not found error when the module has no default export', () => { + vi.mocked(loadAsyncStorageModule).mockReturnValueOnce({}); + + expect(() => getDefaultAsyncStorage()).toThrow(MODULE_NOT_FOUND_REACT_NATIVE_ASYNC_STORAGE); + }); + + it('should return the default export of the async storage module', () => { + const multiGet = vi.fn(); + const storage = { + getItem: vi.fn(), + setItem: vi.fn(), + removeItem: vi.fn(), + getAllKeys: vi.fn(), + clear: vi.fn(), + multiGet, + }; + vi.mocked(loadAsyncStorageModule).mockReturnValueOnce({ default: storage }); + + const result = getDefaultAsyncStorage(); + + expect(result).toBe(storage); + expect(result.multiGet).toBe(multiGet); + }); +}); describe('ensureMultiGet', () => { it('should preserve multiGet when already present (v1/v2)', () => { 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 83881b334..883398a31 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 @@ -15,6 +15,7 @@ */ import { Platform } from '../../../platform_support'; +import { loadAsyncStorageModule } from 'react_native_optional/async_storage'; export interface AsyncStorageCompat { getItem(key: string): Promise; @@ -59,13 +60,11 @@ export const ensureMultiGet = (storage: RawAsyncStorage): AsyncStorageCompat => }; export const getDefaultAsyncStorage = (): AsyncStorageCompat => { - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - const storage = require('@react-native-async-storage/async-storage').default; - return ensureMultiGet(storage); - } catch (e) { + const storage = loadAsyncStorageModule()?.default as RawAsyncStorage | undefined; + if (!storage) { throw new Error(MODULE_NOT_FOUND_REACT_NATIVE_ASYNC_STORAGE); } + return ensureMultiGet(storage); }; export const __platforms: Platform[] = ['__universal__']; diff --git a/lib/utils/import.react_native/optional/async_storage.ts b/lib/utils/import.react_native/optional/async_storage.ts new file mode 100644 index 000000000..db5f6afec --- /dev/null +++ b/lib/utils/import.react_native/optional/async_storage.ts @@ -0,0 +1,35 @@ +/** + * Copyright 2026, 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 { Platform } from '../../../platform_support'; + +// Shipped as its own module so the optional require cannot shift others; see fast_text_encoding.ts. + +// Required while this module loads rather than when loadAsyncStorageModule is called: on Metro before +// 0.82.5, requiring a missing module only throws to the caller during module loading. Outside of it, +// Metro reports the error as fatal and returns undefined instead of throwing. +let asyncStorageModule: { default?: unknown } | undefined; +try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + asyncStorageModule = require('@react-native-async-storage/async-storage'); +} catch (e) { + asyncStorageModule = undefined; +} + +// Returns the @react-native-async-storage/async-storage module, or undefined if it is not available. +export const loadAsyncStorageModule = (): { default?: unknown } | undefined => asyncStorageModule; + +export const __platforms: Platform[] = ['__universal__']; diff --git a/lib/utils/import.react_native/optional/fast_text_encoding.ts b/lib/utils/import.react_native/optional/fast_text_encoding.ts new file mode 100644 index 000000000..fb5b2a077 --- /dev/null +++ b/lib/utils/import.react_native/optional/fast_text_encoding.ts @@ -0,0 +1,39 @@ +/** + * Copyright 2026, 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 { Platform } from '../../../platform_support'; + +// Files in this directory are not bundled into the main SDK bundle; each is shipped as its own +// module under dist/react_native_optional (see rollup.config.mjs). Metro before 0.82.5 drops a missing +// optional dependency from the module's dependency map, which shifts every later require in that module. +// Keeping each optional require alone in its own module limits that to the require itself. + +// murmurhash needs a global TextEncoder. Newer Hermes provides one; older Hermes and JSC do not. +// The try/catch lets Metro treat fast-text-encoding as an optional dependency +// (requires allowOptionalDependencies, which React Native's and Expo's default Metro configs enable). +if (typeof TextEncoder === 'undefined') { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('fast-text-encoding'); + } catch (e) { + throw new Error( + 'TextEncoder is not available in this environment. Install fast-text-encoding, ' + + 'or load another TextEncoder polyfill before importing the SDK.' + ); + } +} + +export const __platforms: Platform[] = ['react_native']; diff --git a/lib/utils/import.react_native/optional/get_random_values.ts b/lib/utils/import.react_native/optional/get_random_values.ts new file mode 100644 index 000000000..e31336e96 --- /dev/null +++ b/lib/utils/import.react_native/optional/get_random_values.ts @@ -0,0 +1,34 @@ +/** + * Copyright 2026, 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 { Platform } from '../../../platform_support'; + +// Shipped as its own module so the optional require cannot shift others; see fast_text_encoding.ts. + +// uuid needs a global crypto.getRandomValues, which React Native does not provide. +if (typeof crypto === 'undefined' || typeof crypto.getRandomValues !== 'function') { + try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('react-native-get-random-values'); + } catch (e) { + throw new Error( + 'crypto.getRandomValues is not available in this environment. Install react-native-get-random-values, ' + + 'or load another crypto.getRandomValues polyfill before importing the SDK.' + ); + } +} + +export const __platforms: Platform[] = ['react_native']; diff --git a/rollup.config.mjs b/rollup.config.mjs index fe3df3d85..f2100f885 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -20,6 +20,7 @@ import terser from '@rollup/plugin-terser'; import alias from '@rollup/plugin-alias'; import { createRequire } from 'module'; import { fileURLToPath } from 'url'; +import { readdirSync } from 'fs'; import { dirname, join } from 'path'; const __filename = fileURLToPath(import.meta.url); @@ -58,6 +59,32 @@ const externalDeps = ['https', 'http', 'url'].concat(Object.keys({ ...dependenci // into the CJS output instead of leaving it as an external dependency. const externalDepsWithoutUuid = externalDeps.filter(dep => dep !== 'uuid'); +// React Native optional-dependency loaders (imported as 'react_native_optional/', see the tsconfig +// paths) are not bundled into the main bundles. Each is shipped as its own module in +// dist/react_native_optional and required from the main bundle by relative path, because Metro before +// 0.82.5 shifts every later require in a module when an optional dependency is missing. +// See lib/utils/import.react_native/optional/fast_text_encoding.ts. +const RN_OPTIONAL = 'react_native_optional'; +const RN_OPTIONAL_LOADERS = readdirSync(join(__dirname, 'lib/utils/import.react_native/optional')) + .filter(file => file.endsWith('.ts') && !file.endsWith('.spec.ts')) + .map(file => file.replace(/\.ts$/, '')); + +const isRnOptionalLoader = id => id.startsWith(`${RN_OPTIONAL}/`); + +const externalWithRnOptionalLoaders = deps => id => deps.includes(id) || isRnOptionalLoader(id); + +const rnOptionalLoaderPaths = id => (isRnOptionalLoader(id) ? `./${id}.js` : id); + +const rnOptionalLoaderBundle = name => ({ + external: externalDeps, + input: join(__dirname, `.build/utils/import.react_native/optional/${name}.js`), + output: { + exports: 'named', + format: 'cjs', + file: `dist/${RN_OPTIONAL}/${name}.js`, + }, +}); + const cjsBundleFor = (platform, opt = {}) => { const { minify, ext } = { minify: true, @@ -69,12 +96,13 @@ const cjsBundleFor = (platform, opt = {}) => { return { plugins: [aliasPlugin, resolvePluginByPlatform[platform] || resolvePlugin, commonjs()], - external: externalDepsWithoutUuid, + external: externalWithRnOptionalLoaders(externalDepsWithoutUuid), input: `.build/index.${platform}.js`, output: { exports: 'named', format: 'cjs', file: `dist/index.${platform}${min}${ext}`, + paths: rnOptionalLoaderPaths, plugins: minify ? [terser()] : undefined, sourcemap: true, }, @@ -92,11 +120,12 @@ const esmBundleFor = (platform, opt) => { return { ...cjsBundleFor(platform), - external: externalDeps, + external: externalWithRnOptionalLoaders(externalDeps), output: [ { format: 'es', file: `dist/index.${platform}.es${min}${ext}`, + paths: rnOptionalLoaderPaths, plugins: minify ? [terser()] : undefined, sourcemap: true, }, @@ -198,6 +227,9 @@ const bundles = { 'esm-node-min': esmBundleFor('node', { ext: '.mjs' }), 'esm-react-native-min': esmBundleFor('react_native'), 'esm-universal': esmBundleFor('universal'), + ...Object.fromEntries( + RN_OPTIONAL_LOADERS.map(name => [`cjs-react-native-optional-${name}`, rnOptionalLoaderBundle(name)]) + ), 'json-schema': jsonSchemaBundle, 'cjs-ua-parser-min': cjsBundleForUAParser(), 'esm-ua-parser-min': esmBundleForUAParser(), diff --git a/tsconfig.json b/tsconfig.json index 25be5af80..c18a6a9b5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,6 +24,9 @@ "log_message": [ "./lib/message/log_message" ], + "react_native_optional/*": [ + "./lib/utils/import.react_native/optional/*" + ], }, "resolveJsonModule": true, "allowJs": true, diff --git a/vitest.config.mts b/vitest.config.mts index e0219348e..be5dfa03f 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -21,6 +21,7 @@ export default defineConfig({ alias: { 'error_message': path.resolve(__dirname, './lib/message/error_message'), 'log_message': path.resolve(__dirname, './lib/message/log_message'), + 'react_native_optional': path.resolve(__dirname, './lib/utils/import.react_native/optional'), }, }, test: { From 9a46d920cadfcb2654447cfa8d1fe90489fbd867 Mon Sep 17 00:00:00 2001 From: Raju Ahmed Date: Wed, 14 Oct 2026 15:21:52 +0600 Subject: [PATCH 2/6] move react native optional loaders to lib/react_native_optional The loaders now live in lib/react_native_optional and are built to the same path in dist, so the main bundles can require them by the same relative path as in the source. This replaces the react_native_optional/* path alias and the rollup output.paths rewrite with a small resolveId plugin, and builds all loaders in one multi-input rollup build. Co-Authored-By: Claude Opus 5.5 --- ...ent_processor_factory.react_native.spec.ts | 2 +- lib/index.react_native.ts | 4 +- ...onfig_manager_factory.react_native.spec.ts | 2 +- .../async_storage.ts | 2 +- .../fast_text_encoding.ts | 2 +- .../get_random_values.ts | 2 +- .../async-storage.spec.ts | 4 +- .../async-storage.ts | 2 +- rollup.config.mjs | 61 +++++++++++-------- tsconfig.json | 3 - vitest.config.mts | 1 - 11 files changed, 46 insertions(+), 39 deletions(-) rename lib/{utils/import.react_native/optional => react_native_optional}/async_storage.ts (96%) rename lib/{utils/import.react_native/optional => react_native_optional}/fast_text_encoding.ts (96%) rename lib/{utils/import.react_native/optional => react_native_optional}/get_random_values.ts (95%) 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 0629ed48d..9a27d0b5a 100644 --- a/lib/event_processor/event_processor_factory.react_native.spec.ts +++ b/lib/event_processor/event_processor_factory.react_native.spec.ts @@ -56,7 +56,7 @@ vi.mock('@react-native-community/netinfo', () => { let isAsyncStorageAvailable = true; // Simulate a missing @react-native-async-storage/async-storage -vi.mock('react_native_optional/async_storage', () => ({ +vi.mock('../react_native_optional/async_storage', () => ({ loadAsyncStorageModule: () => (isAsyncStorageAvailable ? { default: {} } : undefined), })); diff --git a/lib/index.react_native.ts b/lib/index.react_native.ts index 08fba7452..c39de0c33 100644 --- a/lib/index.react_native.ts +++ b/lib/index.react_native.ts @@ -15,8 +15,8 @@ */ // Each polyfill is only loaded when its global is missing, so apps can provide their own polyfill // instead by loading it before the SDK is imported. -import 'react_native_optional/fast_text_encoding'; -import 'react_native_optional/get_random_values'; +import './react_native_optional/fast_text_encoding'; +import './react_native_optional/get_random_values'; import { Client, Config } from './shared_types'; import { getOptimizelyInstance, OptimizelyFactoryConfig } from './client_factory'; diff --git a/lib/project_config/config_manager_factory.react_native.spec.ts b/lib/project_config/config_manager_factory.react_native.spec.ts index 195b3683d..a60070529 100644 --- a/lib/project_config/config_manager_factory.react_native.spec.ts +++ b/lib/project_config/config_manager_factory.react_native.spec.ts @@ -18,7 +18,7 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; let isAsyncStorageAvailable = true; -vi.mock('react_native_optional/async_storage', () => ({ +vi.mock('../react_native_optional/async_storage', () => ({ loadAsyncStorageModule: () => (isAsyncStorageAvailable ? { default: {} } : undefined), })); diff --git a/lib/utils/import.react_native/optional/async_storage.ts b/lib/react_native_optional/async_storage.ts similarity index 96% rename from lib/utils/import.react_native/optional/async_storage.ts rename to lib/react_native_optional/async_storage.ts index db5f6afec..574a883f7 100644 --- a/lib/utils/import.react_native/optional/async_storage.ts +++ b/lib/react_native_optional/async_storage.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Platform } from '../../../platform_support'; +import { Platform } from '../platform_support'; // Shipped as its own module so the optional require cannot shift others; see fast_text_encoding.ts. diff --git a/lib/utils/import.react_native/optional/fast_text_encoding.ts b/lib/react_native_optional/fast_text_encoding.ts similarity index 96% rename from lib/utils/import.react_native/optional/fast_text_encoding.ts rename to lib/react_native_optional/fast_text_encoding.ts index fb5b2a077..9ee352e0f 100644 --- a/lib/utils/import.react_native/optional/fast_text_encoding.ts +++ b/lib/react_native_optional/fast_text_encoding.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Platform } from '../../../platform_support'; +import { Platform } from '../platform_support'; // Files in this directory are not bundled into the main SDK bundle; each is shipped as its own // module under dist/react_native_optional (see rollup.config.mjs). Metro before 0.82.5 drops a missing diff --git a/lib/utils/import.react_native/optional/get_random_values.ts b/lib/react_native_optional/get_random_values.ts similarity index 95% rename from lib/utils/import.react_native/optional/get_random_values.ts rename to lib/react_native_optional/get_random_values.ts index e31336e96..ca2e6e043 100644 --- a/lib/utils/import.react_native/optional/get_random_values.ts +++ b/lib/react_native_optional/get_random_values.ts @@ -14,7 +14,7 @@ * limitations under the License. */ -import { Platform } from '../../../platform_support'; +import { Platform } from '../platform_support'; // Shipped as its own module so the optional require cannot shift others; see fast_text_encoding.ts. 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 a675dd1ae..be81becb4 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 @@ -16,12 +16,12 @@ import { vi, describe, it, expect } from 'vitest'; -vi.mock('react_native_optional/async_storage', () => ({ +vi.mock('../../../react_native_optional/async_storage', () => ({ loadAsyncStorageModule: vi.fn(), })); import { ensureMultiGet, getDefaultAsyncStorage, MODULE_NOT_FOUND_REACT_NATIVE_ASYNC_STORAGE } from './async-storage'; -import { loadAsyncStorageModule } from 'react_native_optional/async_storage'; +import { loadAsyncStorageModule } from '../../../react_native_optional/async_storage'; describe('getDefaultAsyncStorage', () => { it('should throw module not found error when async storage is not installed', () => { 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 883398a31..2aff5f1e1 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 @@ -15,7 +15,7 @@ */ import { Platform } from '../../../platform_support'; -import { loadAsyncStorageModule } from 'react_native_optional/async_storage'; +import { loadAsyncStorageModule } from '../../../react_native_optional/async_storage'; export interface AsyncStorageCompat { getItem(key: string): Promise; diff --git a/rollup.config.mjs b/rollup.config.mjs index f2100f885..3d3980c7f 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -21,7 +21,7 @@ import alias from '@rollup/plugin-alias'; import { createRequire } from 'module'; import { fileURLToPath } from 'url'; import { readdirSync } from 'fs'; -import { dirname, join } from 'path'; +import { dirname, join, relative, resolve } from 'path'; const __filename = fileURLToPath(import.meta.url); const __dirname = dirname(__filename); @@ -59,31 +59,46 @@ const externalDeps = ['https', 'http', 'url'].concat(Object.keys({ ...dependenci // into the CJS output instead of leaving it as an external dependency. const externalDepsWithoutUuid = externalDeps.filter(dep => dep !== 'uuid'); -// React Native optional-dependency loaders (imported as 'react_native_optional/', see the tsconfig -// paths) are not bundled into the main bundles. Each is shipped as its own module in -// dist/react_native_optional and required from the main bundle by relative path, because Metro before -// 0.82.5 shifts every later require in a module when an optional dependency is missing. -// See lib/utils/import.react_native/optional/fast_text_encoding.ts. -const RN_OPTIONAL = 'react_native_optional'; -const RN_OPTIONAL_LOADERS = readdirSync(join(__dirname, 'lib/utils/import.react_native/optional')) +// React Native optional-dependency loaders (lib/react_native_optional) are not bundled into the +// main bundles. Each is shipped as its own module at the same relative location in dist, because Metro +// before 0.82.5 shifts every later require in a module when an optional dependency is missing. +// See lib/react_native_optional/fast_text_encoding.ts. +const BUILD_DIR = join(__dirname, '.build'); +const RN_OPTIONAL_DIR = 'react_native_optional'; +const RN_OPTIONAL_LOADERS = readdirSync(join(__dirname, 'lib', RN_OPTIONAL_DIR)) .filter(file => file.endsWith('.ts') && !file.endsWith('.spec.ts')) .map(file => file.replace(/\.ts$/, '')); -const isRnOptionalLoader = id => id.startsWith(`${RN_OPTIONAL}/`); - -const externalWithRnOptionalLoaders = deps => id => deps.includes(id) || isRnOptionalLoader(id); - -const rnOptionalLoaderPaths = id => (isRnOptionalLoader(id) ? `./${id}.js` : id); +// Leaves loader imports out of the main bundles and writes them as a path relative to the bundle, e.g. +// import '../../../react_native_optional/async_storage' (from a file deep in .build) +// -> require("./react_native_optional/async_storage.js") (in dist/index.react_native.min.js) +// The bundles are at the root of dist and the loaders are built to dist/react_native_optional, +// so that path points to the built loader. +// Marking the imports external via the `external` option is not enough: Rollup would resolve them to the +// file in .build and write a path from the bundle back into .build (../.build/react_native_optional/...). +// Returning a relative id from resolveId makes Rollup write it exactly as given. +const rnOptionalExternalPlugin = { + name: 'rn-optional-external', + resolveId(source, importer) { + if (!importer || !source.startsWith('.')) return null; + const pathInBuild = relative(BUILD_DIR, resolve(dirname(importer), source)).replace(/\.js$/, ''); + if (!pathInBuild.startsWith(`${RN_OPTIONAL_DIR}/`)) return null; + return { id: `./${pathInBuild}.js`, external: true }; + }, +}; -const rnOptionalLoaderBundle = name => ({ +const rnOptionalLoadersBundle = { external: externalDeps, - input: join(__dirname, `.build/utils/import.react_native/optional/${name}.js`), + input: Object.fromEntries( + RN_OPTIONAL_LOADERS.map(name => [name, join(BUILD_DIR, RN_OPTIONAL_DIR, `${name}.js`)]) + ), output: { exports: 'named', format: 'cjs', - file: `dist/${RN_OPTIONAL}/${name}.js`, + dir: join('dist', RN_OPTIONAL_DIR), + entryFileNames: '[name].js', }, -}); +}; const cjsBundleFor = (platform, opt = {}) => { const { minify, ext } = { @@ -95,14 +110,13 @@ const cjsBundleFor = (platform, opt = {}) => { const min = minify ? '.min' : ''; return { - plugins: [aliasPlugin, resolvePluginByPlatform[platform] || resolvePlugin, commonjs()], - external: externalWithRnOptionalLoaders(externalDepsWithoutUuid), + plugins: [rnOptionalExternalPlugin, aliasPlugin, resolvePluginByPlatform[platform] || resolvePlugin, commonjs()], + external: externalDepsWithoutUuid, input: `.build/index.${platform}.js`, output: { exports: 'named', format: 'cjs', file: `dist/index.${platform}${min}${ext}`, - paths: rnOptionalLoaderPaths, plugins: minify ? [terser()] : undefined, sourcemap: true, }, @@ -120,12 +134,11 @@ const esmBundleFor = (platform, opt) => { return { ...cjsBundleFor(platform), - external: externalWithRnOptionalLoaders(externalDeps), + external: externalDeps, output: [ { format: 'es', file: `dist/index.${platform}.es${min}${ext}`, - paths: rnOptionalLoaderPaths, plugins: minify ? [terser()] : undefined, sourcemap: true, }, @@ -227,9 +240,7 @@ const bundles = { 'esm-node-min': esmBundleFor('node', { ext: '.mjs' }), 'esm-react-native-min': esmBundleFor('react_native'), 'esm-universal': esmBundleFor('universal'), - ...Object.fromEntries( - RN_OPTIONAL_LOADERS.map(name => [`cjs-react-native-optional-${name}`, rnOptionalLoaderBundle(name)]) - ), + 'cjs-react-native-optional': rnOptionalLoadersBundle, 'json-schema': jsonSchemaBundle, 'cjs-ua-parser-min': cjsBundleForUAParser(), 'esm-ua-parser-min': esmBundleForUAParser(), diff --git a/tsconfig.json b/tsconfig.json index c18a6a9b5..25be5af80 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -24,9 +24,6 @@ "log_message": [ "./lib/message/log_message" ], - "react_native_optional/*": [ - "./lib/utils/import.react_native/optional/*" - ], }, "resolveJsonModule": true, "allowJs": true, diff --git a/vitest.config.mts b/vitest.config.mts index be5dfa03f..e0219348e 100644 --- a/vitest.config.mts +++ b/vitest.config.mts @@ -21,7 +21,6 @@ export default defineConfig({ alias: { 'error_message': path.resolve(__dirname, './lib/message/error_message'), 'log_message': path.resolve(__dirname, './lib/message/log_message'), - 'react_native_optional': path.resolve(__dirname, './lib/utils/import.react_native/optional'), }, }, test: { From 428066ea8e1e7de1800139804442b8c33f149cfd Mon Sep 17 00:00:00 2001 From: Raju Ahmed Date: Thu, 15 Oct 2026 03:01:31 +0600 Subject: [PATCH 3/6] [FSSDK-13287] restructure react native optional import shims Shims now live in lib/import.react_native, are named after the package they require, and export isAvailable plus the package's exports used by the SDK. They never throw; importers handle a missing package: - index.react_native.ts throws if TextEncoder or crypto.getRandomValues is still missing after the polyfill shims run - AsyncStorageCache throws "Module not found" when AsyncStorage is missing The async-storage compat helper is merged into its shim, which applies ensureMultiGet right after the require and exports the result as default. The CJS bundles read shim default exports with Rollup's esModule interop. A rollup check fails the build unless every built shim contains exactly one require(), of its own package. A README in the shim directory explains the Metro bug behind this setup and the rules for writing a shim. Co-Authored-By: Claude Opus 5.5 --- ...ent_processor_factory.react_native.spec.ts | 8 +- .../async-storage.spec.ts | 103 +++++++-------- .../async-storage.ts | 44 +++--- lib/import.react_native/README.md | 125 ++++++++++++++++++ .../fast-text-encoding.ts} | 21 ++- .../react-native-get-random-values.ts | 31 +++++ lib/index.react_native.ts | 25 +++- ...onfig_manager_factory.react_native.spec.ts | 5 +- lib/react_native_optional/async_storage.ts | 35 ----- .../fast_text_encoding.ts | 39 ------ .../async_storage_cache.react_native.spec.ts | 4 +- .../cache/async_storage_cache.react_native.ts | 11 +- rollup.config.mjs | 82 ++++++++---- 13 files changed, 341 insertions(+), 192 deletions(-) rename lib/{utils => }/import.react_native/@react-native-async-storage/async-storage.spec.ts (51%) rename lib/{utils => }/import.react_native/@react-native-async-storage/async-storage.ts (61%) create mode 100644 lib/import.react_native/README.md rename lib/{react_native_optional/get_random_values.ts => import.react_native/fast-text-encoding.ts} (52%) create mode 100644 lib/import.react_native/react-native-get-random-values.ts delete mode 100644 lib/react_native_optional/async_storage.ts delete mode 100644 lib/react_native_optional/fast_text_encoding.ts 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 9a27d0b5a..8261e9851 100644 --- a/lib/event_processor/event_processor_factory.react_native.spec.ts +++ b/lib/event_processor/event_processor_factory.react_native.spec.ts @@ -56,8 +56,9 @@ vi.mock('@react-native-community/netinfo', () => { let isAsyncStorageAvailable = true; // Simulate a missing @react-native-async-storage/async-storage -vi.mock('../react_native_optional/async_storage', () => ({ - loadAsyncStorageModule: () => (isAsyncStorageAvailable ? { default: {} } : undefined), +vi.mock('../import.react_native/@react-native-async-storage/async-storage', () => ({ + get isAvailable() { return isAsyncStorageAvailable; }, + get default() { return isAsyncStorageAvailable ? {} : undefined; }, })); import { createForwardingEventProcessor, createBatchEventProcessor } from './event_processor_factory.react_native'; @@ -67,7 +68,6 @@ import { getOpaqueBatchEventProcessor } from './event_processor_factory'; import { AsyncStore, AsyncPrefixStore, SyncStore, SyncPrefixStore } from '../utils/cache/store'; import { AsyncStorageCache } from '../utils/cache/async_storage_cache.react_native'; import { EVENT_STORE_PREFIX, EventStore } from './event_store'; -import { MODULE_NOT_FOUND_REACT_NATIVE_ASYNC_STORAGE } from '../utils/import.react_native/@react-native-async-storage/async-storage'; describe('createForwardingEventProcessor', () => { const mockGetForwardingEventProcessor = vi.mocked(getForwardingEventProcessor); @@ -143,7 +143,7 @@ describe('createBatchEventProcessor', () => { it('should throw error if @react-native-async-storage/async-storage is not available', async () => { isAsyncStorageAvailable = false; - const { AsyncStorageCache } = await vi.importActual< + const { AsyncStorageCache, MODULE_NOT_FOUND_REACT_NATIVE_ASYNC_STORAGE } = await vi.importActual< typeof import('../utils/cache/async_storage_cache.react_native') >('../utils/cache/async_storage_cache.react_native'); diff --git a/lib/utils/import.react_native/@react-native-async-storage/async-storage.spec.ts b/lib/import.react_native/@react-native-async-storage/async-storage.spec.ts similarity index 51% rename from lib/utils/import.react_native/@react-native-async-storage/async-storage.spec.ts rename to lib/import.react_native/@react-native-async-storage/async-storage.spec.ts index be81becb4..8d0d614a8 100644 --- a/lib/utils/import.react_native/@react-native-async-storage/async-storage.spec.ts +++ b/lib/import.react_native/@react-native-async-storage/async-storage.spec.ts @@ -1,5 +1,5 @@ /** - * Copyright 2025, Optimizely + * Copyright 2025-2026, Optimizely * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -15,48 +15,45 @@ */ import { vi, describe, it, expect } from 'vitest'; - -vi.mock('../../../react_native_optional/async_storage', () => ({ - loadAsyncStorageModule: vi.fn(), -})); - -import { ensureMultiGet, getDefaultAsyncStorage, MODULE_NOT_FOUND_REACT_NATIVE_ASYNC_STORAGE } from './async-storage'; -import { loadAsyncStorageModule } from '../../../react_native_optional/async_storage'; - -describe('getDefaultAsyncStorage', () => { - it('should throw module not found error when async storage is not installed', () => { - vi.mocked(loadAsyncStorageModule).mockReturnValueOnce(undefined); - - expect(() => getDefaultAsyncStorage()).toThrow(MODULE_NOT_FOUND_REACT_NATIVE_ASYNC_STORAGE); +import Module from 'module'; + +// The shim requires the package when it loads, so each case loads a fresh copy of the shim with the +// package's require replaced by `load`. vi.mock does not intercept the shim's plain require. +const loadShimWith = async (load: () => unknown) => { + const M = Module as unknown as { _load: (request: string, ...rest: unknown[]) => unknown }; + const originalLoad = M._load; + M._load = function (this: unknown, request: string, ...rest: unknown[]) { + if (request === '@react-native-async-storage/async-storage') return load(); + return originalLoad.call(this, request, ...rest); + }; + try { + vi.resetModules(); + return await import('./async-storage'); + } finally { + M._load = originalLoad; + } +}; + +const loadShimWithStorage = (storage: unknown) => loadShimWith(() => ({ default: storage })); + +describe('@react-native-async-storage/async-storage shim', () => { + it('should export isAvailable as false and an undefined default when the package is not installed', async () => { + const shim = await loadShimWith(() => { + throw new Error('Cannot find module'); + }); + + expect(shim.isAvailable).toBe(false); + expect(shim.default).toBeUndefined(); }); - it('should throw module not found error when the module has no default export', () => { - vi.mocked(loadAsyncStorageModule).mockReturnValueOnce({}); - - expect(() => getDefaultAsyncStorage()).toThrow(MODULE_NOT_FOUND_REACT_NATIVE_ASYNC_STORAGE); - }); - - it('should return the default export of the async storage module', () => { - const multiGet = vi.fn(); - const storage = { - getItem: vi.fn(), - setItem: vi.fn(), - removeItem: vi.fn(), - getAllKeys: vi.fn(), - clear: vi.fn(), - multiGet, - }; - vi.mocked(loadAsyncStorageModule).mockReturnValueOnce({ default: storage }); - - const result = getDefaultAsyncStorage(); + it('should export an undefined default when the package has no default export', async () => { + const shim = await loadShimWithStorage(undefined); - expect(result).toBe(storage); - expect(result.multiGet).toBe(multiGet); + expect(shim.isAvailable).toBe(true); + expect(shim.default).toBeUndefined(); }); -}); -describe('ensureMultiGet', () => { - it('should preserve multiGet when already present (v1/v2)', () => { + it('should export the package default as-is when multiGet is present (v1/v2)', async () => { const multiGet = vi.fn(); const storage = { getItem: vi.fn(), @@ -67,9 +64,11 @@ describe('ensureMultiGet', () => { multiGet, }; - const result = ensureMultiGet(storage); + const shim = await loadShimWithStorage(storage); - expect(result.multiGet).toBe(multiGet); + expect(shim.isAvailable).toBe(true); + expect(shim.default).toBe(storage); + expect(shim.default?.multiGet).toBe(multiGet); }); it('should construct multiGet from getMany when multiGet is absent (v3)', async () => { @@ -87,11 +86,11 @@ describe('ensureMultiGet', () => { }), }; - const result = ensureMultiGet(storage); + const result = (await loadShimWithStorage(storage)).default; - expect(result.multiGet).toBeDefined(); + expect(result?.multiGet).toBeDefined(); - const pairs = await result.multiGet(['b', 'missing', 'a']); + const pairs = await result?.multiGet(['b', 'missing', 'a']); expect(pairs).toEqual([ ['b', '2'], ['missing', null], @@ -99,7 +98,7 @@ describe('ensureMultiGet', () => { ]); }); - it('should not modify the original storage when constructing multiGet (v3)', () => { + it('should not modify the original storage when constructing multiGet (v3)', async () => { const storage = { getItem: vi.fn(), setItem: vi.fn(), @@ -109,16 +108,16 @@ describe('ensureMultiGet', () => { getMany: vi.fn(), }; - const result = ensureMultiGet(storage); + const result = (await loadShimWithStorage(storage)).default; 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); + 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 () => { @@ -137,8 +136,8 @@ describe('ensureMultiGet', () => { }), }; - const result = ensureMultiGet(storage); - const pairs = await result.multiGet(['z', 'x', 'y']); + const result = (await loadShimWithStorage(storage)).default; + const pairs = await result?.multiGet(['z', 'x', 'y']); expect(pairs).toEqual([ ['z', 'vz'], diff --git a/lib/utils/import.react_native/@react-native-async-storage/async-storage.ts b/lib/import.react_native/@react-native-async-storage/async-storage.ts similarity index 61% rename from lib/utils/import.react_native/@react-native-async-storage/async-storage.ts rename to lib/import.react_native/@react-native-async-storage/async-storage.ts index 2aff5f1e1..62a51deba 100644 --- a/lib/utils/import.react_native/@react-native-async-storage/async-storage.ts +++ b/lib/import.react_native/@react-native-async-storage/async-storage.ts @@ -1,11 +1,11 @@ /** - * Copyright 2024, Optimizely + * Copyright 2024-2026, 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 * - * http://www.apache.org/licenses/LICENSE-2.0 + * 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, @@ -14,8 +14,9 @@ * limitations under the License. */ -import { Platform } from '../../../platform_support'; -import { loadAsyncStorageModule } from '../../../react_native_optional/async_storage'; +import { Platform } from '../../platform_support'; + +// Optional import shim; see ../README.md. export interface AsyncStorageCompat { getItem(key: string): Promise; @@ -36,35 +37,42 @@ interface RawAsyncStorage { getMany?(keys: readonly string[]): Promise>; } -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 => { +// AsyncStorage v3 replaced multiGet with getMany. Returns a wrapper with the original as its prototype, +// so the shared AsyncStorage object is not mutated. +// multiGet avoids async/await, which would compile to a tslib helper import. +const ensureMultiGet = (storage: RawAsyncStorage): AsyncStorageCompat => { if (storage.multiGet || !storage.getMany) { return storage as AsyncStorageCompat; } return Object.create(storage, { multiGet: { - value: async function( + value: 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]); + return this.getMany(keys).then((record) => + keys.map((key: string) => [key, record[key] ?? null] as [string, string | null]) + ); }, }, }); }; -export const getDefaultAsyncStorage = (): AsyncStorageCompat => { - const storage = loadAsyncStorageModule()?.default as RawAsyncStorage | undefined; - if (!storage) { - throw new Error(MODULE_NOT_FOUND_REACT_NATIVE_ASYNC_STORAGE); - } - return ensureMultiGet(storage); -}; +export let isAvailable = true; + +let storage: RawAsyncStorage | undefined; + +try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + storage = require('@react-native-async-storage/async-storage').default; +} catch (e) { + isAvailable = false; +} + +// The package's default export, with multiGet added when it is missing (v3). +export default storage ? ensureMultiGet(storage) : undefined; export const __platforms: Platform[] = ['__universal__']; diff --git a/lib/import.react_native/README.md b/lib/import.react_native/README.md new file mode 100644 index 000000000..af5893935 --- /dev/null +++ b/lib/import.react_native/README.md @@ -0,0 +1,125 @@ +# React Native optional import shims + +Each file in this directory loads one optional React Native dependency (a peer dependency the app may not have installed) and tells the rest of the SDK whether it is available. + +The shims are not bundled into the main SDK bundle. Rollup builds each one to the same path under `dist/import.react_native/`, and the main React Native bundles `require` it by relative path (see `rollup.config.mjs`). The rest of this section explains why. + +## Why the shims are separate modules + +### How Metro connects a `require` to a module + +1. **Every file in an app's bundle is a module with a numeric ID.** +2. **When Metro transforms a module, each `require('some-package')` becomes a local index.** Metro lists the module's `require`s in order of appearance and rewrites each one to `_$$_REQUIRE(_dependencyMap[i])`. These indices are fixed at transform time, before Metro checks whether the packages are installed. +3. **When Metro writes the bundle, it gives each module a dependency map.** That's an array mapping local index → module ID, passed as an argument to the module's definition: + + ```js + // the module's code, as transformed: + _$$_REQUIRE(_dependencyMap[16], "fast-text-encoding") + _$$_REQUIRE(_dependencyMap[17], "react-native-get-random-values") + + // the module's definition in the bundle: id 538, then its dependency map + __d(function (...) { /* module code */ }, 538, [76, 27, /* ... */, 541, 547, 556], "…/index.react_native.min.js"); + ``` + +### The bug in Metro before 0.82.5 + +A `require` inside `try/catch` is an *optional dependency*, as long as the app's Metro config has `allowOptionalDependencies` on (React Native's and Expo's defaults turn it on). If the package isn't installed, Metro doesn't fail the build. + +Metro 0.82.4 and older also **removes the missing dependency from the module's dependency map**, so the array closes the gap. The module's code still uses the original indices, so every `require` after the missing one shifts by one. + +This used to hit the SDK when all of its React Native code was one Rollup bundle, meaning one Metro module. Its dependency map ended with: + +| Index | Dependency | +|---|---| +| 14 | `@react-native-async-storage/async-storage` (optional) | +| 15 | `@react-native-community/netinfo` | +| 16 | `fast-text-encoding` (optional) | +| 17 | `react-native-get-random-values` (optional) | + +With `fast-text-encoding` not installed, index 16 got `react-native-get-random-values`'s module ID and index 17 pointed past the end of the array: + +- **`require("fast-text-encoding")` silently loaded `react-native-get-random-values`.** No error was thrown, no `TextEncoder` was set up, and decisions failed later. +- **`require("react-native-get-random-values")` became `require(undefined)`,** which throws "Requiring unknown module". The SDK then reported "install react-native-get-random-values" even though that package was installed. +- **A missing async-storage would have shifted netinfo and both polyfill `require`s.** + +How `require(undefined)` fails also depends on timing: +- **While a module is loading:** the error is thrown to the caller, so a `try/catch` around the `require` catches it. +- **Any other time** (for example a `require` inside a function called later): Metro's loader catches the error itself and reports it as a fatal error. That's a red box in dev and usually a crash in release. The caller's `catch` never sees it. + +Metro 0.82.5 fixed the bug by keeping a `null` placeholder for a missing dependency. Requiring `null` throws "Cannot find module", wherever it happens. + +**Who's affected:** apps on React Native 0.78 or older, and on 0.79 or 0.80 if their Metro is below 0.82.5. Apps with every optional package installed aren't affected, because nothing gets removed. + +### How the shims avoid it + +- **Each optional `require` is alone in its own module.** A shim's dependency map has a single entry: its package. If the package is missing, the map is empty and the `require` becomes `require(undefined)`. There is no other `require` in the module that could shift. +- **Each shim does its `require` while the module loads.** The `require(undefined)` error is then thrown to the shim's `try/catch`, which sets `isAvailable = false`. On Metro 0.82.5 and later the `require` throws "Cannot find module", with the same result. +- **The main bundle loads shims by relative path,** for example `require("./import.react_native/fast-text-encoding.js")`. Those files always exist, so the main bundle's dependency map never has a missing entry, and none of its `require`s can shift. + +The rules below keep those guarantees. + +## Rules for a shim + +### One file per optional dependency, named after the package + +Two optional `require`s in one module could shift each other, as described above, so each optional package gets its own shim. The file path is the package name plus `.ts`. A scoped package goes in a folder named after its scope. + +| Package | Shim | +|---|---| +| `fast-text-encoding` | `fast-text-encoding.ts` | +| `react-native-get-random-values` | `react-native-get-random-values.ts` | +| `@react-native-async-storage/async-storage` | `@react-native-async-storage/async-storage.ts` | + +A test goes next to its shim as `.spec.ts`. Spec files are not built as shims. + +### Exports: `isAvailable`, plus what the SDK uses from the package + +- **`isAvailable`:** `true` if the package loaded, `false` if its `require` threw. +- **The package's exports, under the same names:** if the package isn't installed they are `undefined`. A shim may adapt a value as long as it keeps the package's shape. For example, the async-storage shim adds `multiGet` on AsyncStorage v3. +- **Only the exports the SDK uses:** this isn't required for correctness. Re-exporting more would still be one `require`. It just keeps the shims small. +- **Packages loaded only for side effects:** polyfills like `fast-text-encoding` only need `isAvailable`. +- **A shim can use `export default` when the package has a default export,** as the async-storage shim does. This works because of a setting in `rollup.config.mjs`: + - Shims are built as CommonJS. A default export becomes `exports.default`, and the file is marked with `__esModule`. + - In the main CJS bundles, Rollup by default treats a default import from an external CommonJS module as the whole `module.exports`. `import AsyncStorage from '…/async-storage'` would then get the shim's exports object instead of the storage. + - `rollup.config.mjs` therefore sets `interop: 'esModule'` for shim imports, so the CJS bundles read `exports.default`. + - Shims are only built as CommonJS; the ESM main bundle imports the same CommonJS files. It doesn't need the setting: the app's bundler (Metro, or webpack) reads `exports.default` for a default import from a CommonJS module marked with `__esModule`. +- **Never throw.** A shim catches the `require` error, sets `isAvailable = false`, and leaves its exports `undefined`. The importer decides what a missing package means. For example, `index.react_native.ts` throws if `TextEncoder` is still missing after the polyfill shims run, and `AsyncStorageCache` throws "Module not found" when there's no AsyncStorage. + +### Exactly one `require` in the built file + +The optional `require` must be the only `require` in the built shim (`dist/import.react_native/.js`). Any other `require` in the module could shift when the optional package is missing. + +**The build enforces this.** The `rn-import-shim-check` plugin in `rollup.config.mjs` fails the build if a built shim contains any `require` other than its own package's. The package is taken from the shim's name, so the check also enforces the naming convention. + +- **Wrap the `require` directly in `try/catch`.** Metro only treats a `require` as optional when it is directly inside a `try` block. Without that, a missing package fails the app's build. +- **Run it at module load, not later in a function.** Only then is a failed `require` thrown to the shim's `catch`. Later, Metro before 0.82.5 reports it as a fatal error instead (see "The bug in Metro before 0.82.5"). +- **Type-only imports are fine.** TypeScript removes them, for example `import { Platform } from '../platform_support'`. +- **No other runtime imports, not even of guaranteed packages.** Each of these adds a `require` to the built shim: + - A package import, even of a regular dependency, stays a `require` in the shim. It would only be safe if Metro listed it before the optional `require` in the shim's dependency map, and Metro's inline requires move imports around, so that order can't be relied on. + - SDK code imported by more than one shim goes into a shared file that each of them `require`s. + + SDK code imported by only one shim is copied into it and adds no `require`, so the check allows it. It is a separate copy from the one in the main bundle, though, so any module state is not shared. Keep shims self-contained. +- **No syntax that needs `tslib` helpers.** With the repo's ES6 target, `async`/`await` compiles to a `tslib` `__awaiter` import, which adds a `require('tslib')`. Use `.then()` instead. +- **Export `__platforms`,** as every file in `lib/` does for the platform-isolation check. + +## Template + +```ts +import { Platform } from '../platform_support'; + +export let isAvailable = true; + +// Only the exports the SDK uses, under the package's names. +export let someExport: SomeType | undefined; + +try { + // eslint-disable-next-line @typescript-eslint/no-var-requires + someExport = require('some-package').someExport; +} catch (e) { + isAvailable = false; +} + +export const __platforms: Platform[] = ['react_native']; +``` + +Importers use a shim through a normal relative import, for example `import { isAvailable, someExport } from './import.react_native/some-package'`. The Rollup plugin turns that into the relative `require` of the built shim. diff --git a/lib/react_native_optional/get_random_values.ts b/lib/import.react_native/fast-text-encoding.ts similarity index 52% rename from lib/react_native_optional/get_random_values.ts rename to lib/import.react_native/fast-text-encoding.ts index ca2e6e043..0176a74b2 100644 --- a/lib/react_native_optional/get_random_values.ts +++ b/lib/import.react_native/fast-text-encoding.ts @@ -16,19 +16,16 @@ import { Platform } from '../platform_support'; -// Shipped as its own module so the optional require cannot shift others; see fast_text_encoding.ts. +// Optional import shim; see README.md in this directory. -// uuid needs a global crypto.getRandomValues, which React Native does not provide. -if (typeof crypto === 'undefined' || typeof crypto.getRandomValues !== 'function') { - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - require('react-native-get-random-values'); - } catch (e) { - throw new Error( - 'crypto.getRandomValues is not available in this environment. Install react-native-get-random-values, ' + - 'or load another crypto.getRandomValues polyfill before importing the SDK.' - ); - } +export let isAvailable = true; + +try { + // Sets a global TextEncoder if there is none; an existing one is kept. + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('fast-text-encoding'); +} catch (e) { + isAvailable = false; } export const __platforms: Platform[] = ['react_native']; diff --git a/lib/import.react_native/react-native-get-random-values.ts b/lib/import.react_native/react-native-get-random-values.ts new file mode 100644 index 000000000..d8564a881 --- /dev/null +++ b/lib/import.react_native/react-native-get-random-values.ts @@ -0,0 +1,31 @@ +/** + * Copyright 2026, 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 { Platform } from '../platform_support'; + +// Optional import shim; see README.md in this directory. + +export let isAvailable = true; + +try { + // Sets a global crypto.getRandomValues if there is none; an existing one is kept. + // eslint-disable-next-line @typescript-eslint/no-var-requires + require('react-native-get-random-values'); +} catch (e) { + isAvailable = false; +} + +export const __platforms: Platform[] = ['react_native']; diff --git a/lib/index.react_native.ts b/lib/index.react_native.ts index c39de0c33..e32dac23d 100644 --- a/lib/index.react_native.ts +++ b/lib/index.react_native.ts @@ -13,10 +13,10 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -// Each polyfill is only loaded when its global is missing, so apps can provide their own polyfill -// instead by loading it before the SDK is imported. -import './react_native_optional/fast_text_encoding'; -import './react_native_optional/get_random_values'; +// Optional polyfills: each is loaded if installed and keeps an existing global, so apps can provide +// their own polyfill instead by loading it before the SDK is imported. +import './import.react_native/fast-text-encoding'; +import './import.react_native/react-native-get-random-values'; import { Client, Config } from './shared_types'; import { getOptimizelyInstance, OptimizelyFactoryConfig } from './client_factory'; @@ -24,6 +24,23 @@ import { REACT_NATIVE_JS_CLIENT_ENGINE } from './utils/enums'; import { EventDispatcher } from './event_processor/event_dispatcher/event_dispatcher'; import { BrowserRequestHandler } from './utils/http_request_handler/request_handler.browser'; import { Platform } from './platform_support'; + +// murmurhash needs a global TextEncoder. Newer Hermes provides one; older Hermes and JSC do not. +if (typeof TextEncoder === 'undefined') { + throw new Error( + 'TextEncoder is not available in this environment. Install fast-text-encoding, ' + + 'or load another TextEncoder polyfill before importing the SDK.' + ); +} + +// uuid needs a global crypto.getRandomValues, which React Native does not provide. +if (typeof crypto === 'undefined' || typeof crypto.getRandomValues !== 'function') { + throw new Error( + 'crypto.getRandomValues is not available in this environment. Install react-native-get-random-values, ' + + 'or load another crypto.getRandomValues polyfill before importing the SDK.' + ); +} + /** * Creates an instance of the Optimizely class * @param {Config} config diff --git a/lib/project_config/config_manager_factory.react_native.spec.ts b/lib/project_config/config_manager_factory.react_native.spec.ts index a60070529..798f64190 100644 --- a/lib/project_config/config_manager_factory.react_native.spec.ts +++ b/lib/project_config/config_manager_factory.react_native.spec.ts @@ -18,8 +18,9 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; let isAsyncStorageAvailable = true; -vi.mock('../react_native_optional/async_storage', () => ({ - loadAsyncStorageModule: () => (isAsyncStorageAvailable ? { default: {} } : undefined), +vi.mock('../import.react_native/@react-native-async-storage/async-storage', () => ({ + get isAvailable() { return isAsyncStorageAvailable; }, + get default() { return isAsyncStorageAvailable ? {} : undefined; }, })); vi.mock('./config_manager_factory', () => { diff --git a/lib/react_native_optional/async_storage.ts b/lib/react_native_optional/async_storage.ts deleted file mode 100644 index 574a883f7..000000000 --- a/lib/react_native_optional/async_storage.ts +++ /dev/null @@ -1,35 +0,0 @@ -/** - * Copyright 2026, 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 { Platform } from '../platform_support'; - -// Shipped as its own module so the optional require cannot shift others; see fast_text_encoding.ts. - -// Required while this module loads rather than when loadAsyncStorageModule is called: on Metro before -// 0.82.5, requiring a missing module only throws to the caller during module loading. Outside of it, -// Metro reports the error as fatal and returns undefined instead of throwing. -let asyncStorageModule: { default?: unknown } | undefined; -try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - asyncStorageModule = require('@react-native-async-storage/async-storage'); -} catch (e) { - asyncStorageModule = undefined; -} - -// Returns the @react-native-async-storage/async-storage module, or undefined if it is not available. -export const loadAsyncStorageModule = (): { default?: unknown } | undefined => asyncStorageModule; - -export const __platforms: Platform[] = ['__universal__']; diff --git a/lib/react_native_optional/fast_text_encoding.ts b/lib/react_native_optional/fast_text_encoding.ts deleted file mode 100644 index 9ee352e0f..000000000 --- a/lib/react_native_optional/fast_text_encoding.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * Copyright 2026, 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 { Platform } from '../platform_support'; - -// Files in this directory are not bundled into the main SDK bundle; each is shipped as its own -// module under dist/react_native_optional (see rollup.config.mjs). Metro before 0.82.5 drops a missing -// optional dependency from the module's dependency map, which shifts every later require in that module. -// Keeping each optional require alone in its own module limits that to the require itself. - -// murmurhash needs a global TextEncoder. Newer Hermes provides one; older Hermes and JSC do not. -// The try/catch lets Metro treat fast-text-encoding as an optional dependency -// (requires allowOptionalDependencies, which React Native's and Expo's default Metro configs enable). -if (typeof TextEncoder === 'undefined') { - try { - // eslint-disable-next-line @typescript-eslint/no-var-requires - require('fast-text-encoding'); - } catch (e) { - throw new Error( - 'TextEncoder is not available in this environment. Install fast-text-encoding, ' + - 'or load another TextEncoder polyfill before importing the SDK.' - ); - } -} - -export const __platforms: Platform[] = ['react_native']; 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 4a8ed0fec..5a32ba2c4 100644 --- a/lib/utils/cache/async_storage_cache.react_native.spec.ts +++ b/lib/utils/cache/async_storage_cache.react_native.spec.ts @@ -17,12 +17,12 @@ 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'; +import AsyncStorage, { AsyncStorageCompat } from '../../import.react_native/@react-native-async-storage/async-storage'; vi.mock('@react-native-async-storage/async-storage'); describe('AsyncStorageCache', () => { - const asyncStorage = getDefaultAsyncStorage(); + const asyncStorage = AsyncStorage as AsyncStorageCompat; it('should store the value as-is in async storage without serialization', async () => { const cache = new AsyncStorageCache(); diff --git a/lib/utils/cache/async_storage_cache.react_native.ts b/lib/utils/cache/async_storage_cache.react_native.ts index 956cd8745..e57247bf2 100644 --- a/lib/utils/cache/async_storage_cache.react_native.ts +++ b/lib/utils/cache/async_storage_cache.react_native.ts @@ -16,9 +16,18 @@ import { Maybe } from "../type"; import { AsyncStore, AsyncStoreWithBatchedGet } from "./store"; -import { getDefaultAsyncStorage } from "../import.react_native/@react-native-async-storage/async-storage"; +import AsyncStorage, { AsyncStorageCompat, isAvailable } from "../../import.react_native/@react-native-async-storage/async-storage"; import { Platform } from '../../platform_support'; +export const MODULE_NOT_FOUND_REACT_NATIVE_ASYNC_STORAGE = 'Module not found: @react-native-async-storage/async-storage'; + +const getDefaultAsyncStorage = (): AsyncStorageCompat => { + if (!isAvailable || !AsyncStorage) { + throw new Error(MODULE_NOT_FOUND_REACT_NATIVE_ASYNC_STORAGE); + } + return AsyncStorage; +}; + export class AsyncStorageCache extends AsyncStoreWithBatchedGet implements AsyncStore { public readonly operation = 'async'; private asyncStorage = getDefaultAsyncStorage(); diff --git a/rollup.config.mjs b/rollup.config.mjs index 3d3980c7f..8efb80331 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -59,43 +59,78 @@ const externalDeps = ['https', 'http', 'url'].concat(Object.keys({ ...dependenci // into the CJS output instead of leaving it as an external dependency. const externalDepsWithoutUuid = externalDeps.filter(dep => dep !== 'uuid'); -// React Native optional-dependency loaders (lib/react_native_optional) are not bundled into the -// main bundles. Each is shipped as its own module at the same relative location in dist, because Metro -// before 0.82.5 shifts every later require in a module when an optional dependency is missing. -// See lib/react_native_optional/fast_text_encoding.ts. +// React Native optional import shims (lib/import.react_native) are not bundled into the main bundles. +// Each is shipped as its own module at the same relative location in dist, because Metro before 0.82.5 +// shifts every later require in a module when an optional dependency is missing. +// See lib/import.react_native/README.md. const BUILD_DIR = join(__dirname, '.build'); -const RN_OPTIONAL_DIR = 'react_native_optional'; -const RN_OPTIONAL_LOADERS = readdirSync(join(__dirname, 'lib', RN_OPTIONAL_DIR)) - .filter(file => file.endsWith('.ts') && !file.endsWith('.spec.ts')) - .map(file => file.replace(/\.ts$/, '')); - -// Leaves loader imports out of the main bundles and writes them as a path relative to the bundle, e.g. -// import '../../../react_native_optional/async_storage' (from a file deep in .build) -// -> require("./react_native_optional/async_storage.js") (in dist/index.react_native.min.js) -// The bundles are at the root of dist and the loaders are built to dist/react_native_optional, -// so that path points to the built loader. +const RN_IMPORT_SHIM_DIR = 'import.react_native'; + +// Shim paths relative to the shim directory, without extension. Shims are named after their package, so +// a scoped package's shim is in a subdirectory, e.g. '@react-native-async-storage/async-storage'. +const listShims = (subdir = '') => + readdirSync(join(__dirname, 'lib', RN_IMPORT_SHIM_DIR, subdir), { withFileTypes: true }).flatMap(entry => { + const path = subdir ? `${subdir}/${entry.name}` : entry.name; + if (entry.isDirectory()) return listShims(path); + return entry.name.endsWith('.ts') && !entry.name.endsWith('.spec.ts') ? [path.replace(/\.ts$/, '')] : []; + }); +const RN_IMPORT_SHIMS = listShims(); + +// Leaves shim imports out of the main bundles and writes them as a path relative to the bundle, e.g. +// import '../../../import.react_native/@react-native-async-storage/async-storage' (from a file deep in .build) +// -> require("./import.react_native/@react-native-async-storage/async-storage.js") (in dist/index.react_native.min.js) +// The bundles are at the root of dist and the shims are built to dist/import.react_native, +// so that path points to the built shim. // Marking the imports external via the `external` option is not enough: Rollup would resolve them to the -// file in .build and write a path from the bundle back into .build (../.build/react_native_optional/...). +// file in .build and write a path from the bundle back into .build (../.build/import.react_native/...). // Returning a relative id from resolveId makes Rollup write it exactly as given. -const rnOptionalExternalPlugin = { - name: 'rn-optional-external', +const rnImportShimExternalPlugin = { + name: 'rn-import-shim-external', resolveId(source, importer) { if (!importer || !source.startsWith('.')) return null; const pathInBuild = relative(BUILD_DIR, resolve(dirname(importer), source)).replace(/\.js$/, ''); - if (!pathInBuild.startsWith(`${RN_OPTIONAL_DIR}/`)) return null; + if (!pathInBuild.startsWith(`${RN_IMPORT_SHIM_DIR}/`)) return null; return { id: `./${pathInBuild}.js`, external: true }; }, }; -const rnOptionalLoadersBundle = { +// Shims are built as CommonJS with __esModule set, so a default import of a shim must read its `default` +// export ('esModule'). Rollup's 'default' interop, used for the other externals, would read the whole module. +const rnImportShimInterop = id => (id && id.startsWith(`./${RN_IMPORT_SHIM_DIR}/`) ? 'esModule' : 'default'); + +// Fails the build unless every built shim contains exactly one require(), of its own package (the shim's +// name). Any other require in a shim could be shifted by the Metro bug when the package is missing. This +// catches imports of other packages, tslib helpers, and code shared between shims (Rollup moves it into a +// shared chunk that the shims require). The shim's own require() is plain code, so Rollup doesn't list it in +// chunk.imports; the generated code is checked instead. +const rnImportShimCheckPlugin = { + name: 'rn-import-shim-check', + generateBundle(_, bundle) { + for (const chunk of Object.values(bundle)) { + if (chunk.type !== 'chunk') continue; + const requireCalls = chunk.code.match(/\brequire\s*\(/g) || []; + const requiredIds = [...chunk.code.matchAll(/\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g)].map(m => m[1]); + const ok = chunk.isEntry && requireCalls.length === 1 && requiredIds.length === 1 && requiredIds[0] === chunk.name; + if (!ok) { + this.error( + `${RN_IMPORT_SHIM_DIR}/${chunk.fileName}: a shim must contain exactly one require(), of its own package` + + ` '${chunk.name}' (found: ${requiredIds.join(', ') || 'none'}). See lib/${RN_IMPORT_SHIM_DIR}/README.md.` + ); + } + } + }, +}; + +const rnImportShimsBundle = { + plugins: [rnImportShimCheckPlugin], external: externalDeps, input: Object.fromEntries( - RN_OPTIONAL_LOADERS.map(name => [name, join(BUILD_DIR, RN_OPTIONAL_DIR, `${name}.js`)]) + RN_IMPORT_SHIMS.map(name => [name, join(BUILD_DIR, RN_IMPORT_SHIM_DIR, `${name}.js`)]) ), output: { exports: 'named', format: 'cjs', - dir: join('dist', RN_OPTIONAL_DIR), + dir: join('dist', RN_IMPORT_SHIM_DIR), entryFileNames: '[name].js', }, }; @@ -110,13 +145,14 @@ const cjsBundleFor = (platform, opt = {}) => { const min = minify ? '.min' : ''; return { - plugins: [rnOptionalExternalPlugin, aliasPlugin, resolvePluginByPlatform[platform] || resolvePlugin, commonjs()], + plugins: [rnImportShimExternalPlugin, aliasPlugin, resolvePluginByPlatform[platform] || resolvePlugin, commonjs()], external: externalDepsWithoutUuid, input: `.build/index.${platform}.js`, output: { exports: 'named', format: 'cjs', file: `dist/index.${platform}${min}${ext}`, + interop: rnImportShimInterop, plugins: minify ? [terser()] : undefined, sourcemap: true, }, @@ -240,7 +276,7 @@ const bundles = { 'esm-node-min': esmBundleFor('node', { ext: '.mjs' }), 'esm-react-native-min': esmBundleFor('react_native'), 'esm-universal': esmBundleFor('universal'), - 'cjs-react-native-optional': rnOptionalLoadersBundle, + 'cjs-react-native-import-shims': rnImportShimsBundle, 'json-schema': jsonSchemaBundle, 'cjs-ua-parser-min': cjsBundleForUAParser(), 'esm-ua-parser-min': esmBundleForUAParser(), From 7a6a68bdea00eec82a64f12173fe25368048917a Mon Sep 17 00:00:00 2001 From: Raju Ahmed Date: Thu, 15 Oct 2026 17:51:25 +0600 Subject: [PATCH 4/6] [FSSDK-13287] exclude react native shim tests from browser tests The shim specs are named after their packages, so the *.react_native.spec.ts exclude doesn't cover them, and they use Node's module loader. Co-Authored-By: Claude Opus 5.5 --- vitest.browser.config.mts | 1 + 1 file changed, 1 insertion(+) diff --git a/vitest.browser.config.mts b/vitest.browser.config.mts index d3d7763c6..4e6095b38 100644 --- a/vitest.browser.config.mts +++ b/vitest.browser.config.mts @@ -165,6 +165,7 @@ export default defineConfig({ ], exclude: [ 'lib/**/*.react_native.spec.ts', + 'lib/import.react_native/**/*.spec.ts', 'lib/**/*.node.spec.ts', 'lib/*.umd.spec.ts' ], From 04b91567c4752c57b940a3e34f1dde455896cb45 Mon Sep 17 00:00:00 2001 From: Raju Ahmed Date: Thu, 15 Oct 2026 19:24:19 +0600 Subject: [PATCH 5/6] [FSSDK-13287] apply ensureMultiGet inside the async-storage shim's try Co-Authored-By: Claude Opus 5.5 --- .../@react-native-async-storage/async-storage.ts | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/import.react_native/@react-native-async-storage/async-storage.ts b/lib/import.react_native/@react-native-async-storage/async-storage.ts index 62a51deba..0a34784ec 100644 --- a/lib/import.react_native/@react-native-async-storage/async-storage.ts +++ b/lib/import.react_native/@react-native-async-storage/async-storage.ts @@ -63,16 +63,17 @@ const ensureMultiGet = (storage: RawAsyncStorage): AsyncStorageCompat => { export let isAvailable = true; -let storage: RawAsyncStorage | undefined; +// The package's default export, with multiGet added when it is missing (v3). +let AsyncStorage: AsyncStorageCompat | undefined; try { // eslint-disable-next-line @typescript-eslint/no-var-requires - storage = require('@react-native-async-storage/async-storage').default; + const storage: RawAsyncStorage | undefined = require('@react-native-async-storage/async-storage').default; + AsyncStorage = storage ? ensureMultiGet(storage) : undefined; } catch (e) { isAvailable = false; } -// The package's default export, with multiGet added when it is missing (v3). -export default storage ? ensureMultiGet(storage) : undefined; +export default AsyncStorage; export const __platforms: Platform[] = ['__universal__']; From ace9f8f55bcb1b65bcbe92ecd045c171acf2b5c2 Mon Sep 17 00:00:00 2001 From: Raju Ahmed Date: Fri, 16 Oct 2026 01:46:04 +0600 Subject: [PATCH 6/6] update --- rollup.config.mjs | 27 +++++++++++++++++++++------ 1 file changed, 21 insertions(+), 6 deletions(-) diff --git a/rollup.config.mjs b/rollup.config.mjs index 8efb80331..aeca0502d 100644 --- a/rollup.config.mjs +++ b/rollup.config.mjs @@ -102,19 +102,34 @@ const rnImportShimInterop = id => (id && id.startsWith(`./${RN_IMPORT_SHIM_DIR}/ // name). Any other require in a shim could be shifted by the Metro bug when the package is missing. This // catches imports of other packages, tslib helpers, and code shared between shims (Rollup moves it into a // shared chunk that the shims require). The shim's own require() is plain code, so Rollup doesn't list it in -// chunk.imports; the generated code is checked instead. +// chunk.imports; the generated code is parsed instead, with Rollup's parser. const rnImportShimCheckPlugin = { name: 'rn-import-shim-check', generateBundle(_, bundle) { + // Module names of every require(...) call in an ESTree AST; a call whose argument is not a string is + // listed as ''. + const findRequireCalls = (node, found = []) => { + if (Array.isArray(node)) { + node.forEach(child => findRequireCalls(child, found)); + } else if (node && typeof node.type === 'string') { + if (node.type === 'CallExpression' && node.callee.type === 'Identifier' && node.callee.name === 'require') { + const [arg] = node.arguments; + found.push(arg && arg.type === 'Literal' && typeof arg.value === 'string' ? arg.value : ''); + } + for (const value of Object.values(node)) { + if (value && typeof value === 'object') findRequireCalls(value, found); + } + } + return found; + }; + for (const chunk of Object.values(bundle)) { if (chunk.type !== 'chunk') continue; - const requireCalls = chunk.code.match(/\brequire\s*\(/g) || []; - const requiredIds = [...chunk.code.matchAll(/\brequire\s*\(\s*['"]([^'"]+)['"]\s*\)/g)].map(m => m[1]); - const ok = chunk.isEntry && requireCalls.length === 1 && requiredIds.length === 1 && requiredIds[0] === chunk.name; - if (!ok) { + const required = findRequireCalls(this.parse(chunk.code)); + if (!chunk.isEntry || required.length !== 1 || required[0] !== chunk.name) { this.error( `${RN_IMPORT_SHIM_DIR}/${chunk.fileName}: a shim must contain exactly one require(), of its own package` + - ` '${chunk.name}' (found: ${requiredIds.join(', ') || 'none'}). See lib/${RN_IMPORT_SHIM_DIR}/README.md.` + ` '${chunk.name}' (found: ${required.join(', ') || 'none'}). See lib/${RN_IMPORT_SHIM_DIR}/README.md.` ); } }