Skip to content

Commit f308ecb

Browse files
raju-opticlaude
andauthored
[FSSDK-13287] Make React Native polyfills optional and isolate optional requires (#1179)
## Summary - **Polyfill packages are optional.** `fast-text-encoding` and `react-native-get-random-values` are loaded if installed. Both keep an existing global, so a native `TextEncoder` (Hermes in RN 0.74+) or an app's own polyfill (for example `react-native-quick-crypto`, imported in the app's entry file before the SDK) is used instead. If `TextEncoder` or `crypto.getRandomValues` is still missing, `index.react_native.ts` throws at import with an error that names the package to install. - **Optional import shims in `lib/import.react_native/`.** There is one shim per optional package, named after the package: `fast-text-encoding.ts`, `react-native-get-random-values.ts` and `@react-native-async-storage/async-storage.ts`. Each one: - does its `require` inside `try/catch` while the module loads; - exports `isAvailable` plus the package's exports that the SDK uses, under the same names, which are `undefined` if the package is missing; - never throws. The importer decides what a missing package means. - **Shims are built as separate files.** Rollup builds each shim to the same path under `dist/import.react_native/`, and the main React Native bundles load them by relative path. A small `resolveId` plugin keeps the shim imports out of the main bundles. The main bundles no longer contain any optional `require`, and the node, browser and universal bundles are unchanged. - **AsyncStorage:** the compat helper (`ensureMultiGet`, which adds `multiGet` on v3) is merged into the async-storage shim. The shim exports the package's `default`, with `multiGet` added. The "Module not found" check moved to `AsyncStorageCache`. The CJS bundles read shim default exports through Rollup's `esModule` interop, applied only to shim imports. - **Build check:** a Rollup plugin fails the build unless every built shim contains exactly one `require()`, of its own package. That catches imports of other packages, `tslib` helpers (from `async`/`await`), and code shared between shims. - **README:** `lib/import.react_native/README.md` explains the Metro bug behind this setup, how the shims avoid it, and the rules for writing a shim. ### Why the shims are separate files Metro before 0.82.5 (React Native 0.78 and older) drops an optional dependency that isn't installed from the module's dependency map, without adjusting the indices in the module's code. Every later `require` in that module then loads the wrong module. With all of the SDK's React Native code in one module: - With `fast-text-encoding` missing, `require("fast-text-encoding")` silently loaded `react-native-get-random-values`. - The `getRandomValues` check then reported "install react-native-get-random-values" even though it was installed. - A missing async-storage would have shifted netinfo and both polyfill `require`s. A shim's only dependency is its own package, so a missing package can't shift anything else, and the main bundle's shim `require`s always resolve. The shim does its `require` while the module loads for a second reason: on old Metro, a failed `require` called later is reported as a fatal error instead of being thrown to the `catch`. Metro 0.82.5 fixed the bug. ### Compatibility Existing apps already install both polyfill packages, because the old code imported them unconditionally. With everything installed, nothing changes. A missing optional package is only tolerated at build time when Metro's `allowOptionalDependencies` is on, which React Native's and Expo's default Metro configs do. --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
1 parent fd652c3 commit f308ecb

12 files changed

Lines changed: 409 additions & 84 deletions

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

Lines changed: 6 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -55,26 +55,11 @@ vi.mock('@react-native-community/netinfo', () => {
5555
});
5656
let isAsyncStorageAvailable = true;
5757

58-
// Mock Node.js module loader to simulate missing @react-native-async-storage/async-storage
59-
vi.hoisted(() => {
60-
mockRequireNetInfo();
61-
});
62-
63-
function mockRequireNetInfo() {
64-
// Dynamically import module synchronously in Node environment
65-
// eslint-disable-next-line @typescript-eslint/no-var-requires
66-
const { Module } = require('module');
67-
const M: any = Module;
68-
69-
M._load_original = M._load;
70-
M._load = (uri: string, parent: string) => {
71-
if (uri === '@react-native-async-storage/async-storage') {
72-
if (isAsyncStorageAvailable) return {};
73-
throw new Error("Module not found: @react-native-async-storage/async-storage");
74-
}
75-
return M._load_original(uri, parent);
76-
};
77-
}
58+
// Simulate a missing @react-native-async-storage/async-storage
59+
vi.mock('../import.react_native/@react-native-async-storage/async-storage', () => ({
60+
get isAvailable() { return isAsyncStorageAvailable; },
61+
get default() { return isAsyncStorageAvailable ? {} : undefined; },
62+
}));
7863

7964
import { createForwardingEventProcessor, createBatchEventProcessor } from './event_processor_factory.react_native';
8065
import defaultEventDispatcher from './event_dispatcher/default_dispatcher.browser';
@@ -83,7 +68,6 @@ import { getOpaqueBatchEventProcessor } from './event_processor_factory';
8368
import { AsyncStore, AsyncPrefixStore, SyncStore, SyncPrefixStore } from '../utils/cache/store';
8469
import { AsyncStorageCache } from '../utils/cache/async_storage_cache.react_native';
8570
import { EVENT_STORE_PREFIX, EventStore } from './event_store';
86-
import { MODULE_NOT_FOUND_REACT_NATIVE_ASYNC_STORAGE } from '../utils/import.react_native/@react-native-async-storage/async-storage';
8771

8872
describe('createForwardingEventProcessor', () => {
8973
const mockGetForwardingEventProcessor = vi.mocked(getForwardingEventProcessor);
@@ -159,7 +143,7 @@ describe('createBatchEventProcessor', () => {
159143

160144
it('should throw error if @react-native-async-storage/async-storage is not available', async () => {
161145
isAsyncStorageAvailable = false;
162-
const { AsyncStorageCache } = await vi.importActual<
146+
const { AsyncStorageCache, MODULE_NOT_FOUND_REACT_NATIVE_ASYNC_STORAGE } = await vi.importActual<
163147
typeof import('../utils/cache/async_storage_cache.react_native')
164148
>('../utils/cache/async_storage_cache.react_native');
165149

lib/utils/import.react_native/@react-native-async-storage/async-storage.spec.ts renamed to lib/import.react_native/@react-native-async-storage/async-storage.spec.ts

Lines changed: 55 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
/**
2-
* Copyright 2025, Optimizely
2+
* Copyright 2025-2026, Optimizely
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
@@ -15,10 +15,45 @@
1515
*/
1616

1717
import { vi, describe, it, expect } from 'vitest';
18-
import { ensureMultiGet } from './async-storage';
18+
import Module from 'module';
1919

20-
describe('ensureMultiGet', () => {
21-
it('should preserve multiGet when already present (v1/v2)', () => {
20+
// The shim requires the package when it loads, so each case loads a fresh copy of the shim with the
21+
// package's require replaced by `load`. vi.mock does not intercept the shim's plain require.
22+
const loadShimWith = async (load: () => unknown) => {
23+
const M = Module as unknown as { _load: (request: string, ...rest: unknown[]) => unknown };
24+
const originalLoad = M._load;
25+
M._load = function (this: unknown, request: string, ...rest: unknown[]) {
26+
if (request === '@react-native-async-storage/async-storage') return load();
27+
return originalLoad.call(this, request, ...rest);
28+
};
29+
try {
30+
vi.resetModules();
31+
return await import('./async-storage');
32+
} finally {
33+
M._load = originalLoad;
34+
}
35+
};
36+
37+
const loadShimWithStorage = (storage: unknown) => loadShimWith(() => ({ default: storage }));
38+
39+
describe('@react-native-async-storage/async-storage shim', () => {
40+
it('should export isAvailable as false and an undefined default when the package is not installed', async () => {
41+
const shim = await loadShimWith(() => {
42+
throw new Error('Cannot find module');
43+
});
44+
45+
expect(shim.isAvailable).toBe(false);
46+
expect(shim.default).toBeUndefined();
47+
});
48+
49+
it('should export an undefined default when the package has no default export', async () => {
50+
const shim = await loadShimWithStorage(undefined);
51+
52+
expect(shim.isAvailable).toBe(true);
53+
expect(shim.default).toBeUndefined();
54+
});
55+
56+
it('should export the package default as-is when multiGet is present (v1/v2)', async () => {
2257
const multiGet = vi.fn();
2358
const storage = {
2459
getItem: vi.fn(),
@@ -29,9 +64,11 @@ describe('ensureMultiGet', () => {
2964
multiGet,
3065
};
3166

32-
const result = ensureMultiGet(storage);
67+
const shim = await loadShimWithStorage(storage);
3368

34-
expect(result.multiGet).toBe(multiGet);
69+
expect(shim.isAvailable).toBe(true);
70+
expect(shim.default).toBe(storage);
71+
expect(shim.default?.multiGet).toBe(multiGet);
3572
});
3673

3774
it('should construct multiGet from getMany when multiGet is absent (v3)', async () => {
@@ -49,19 +86,19 @@ describe('ensureMultiGet', () => {
4986
}),
5087
};
5188

52-
const result = ensureMultiGet(storage);
89+
const result = (await loadShimWithStorage(storage)).default;
5390

54-
expect(result.multiGet).toBeDefined();
91+
expect(result?.multiGet).toBeDefined();
5592

56-
const pairs = await result.multiGet(['b', 'missing', 'a']);
93+
const pairs = await result?.multiGet(['b', 'missing', 'a']);
5794
expect(pairs).toEqual([
5895
['b', '2'],
5996
['missing', null],
6097
['a', '1'],
6198
]);
6299
});
63100

64-
it('should not modify the original storage when constructing multiGet (v3)', () => {
101+
it('should not modify the original storage when constructing multiGet (v3)', async () => {
65102
const storage = {
66103
getItem: vi.fn(),
67104
setItem: vi.fn(),
@@ -71,16 +108,16 @@ describe('ensureMultiGet', () => {
71108
getMany: vi.fn(),
72109
};
73110

74-
const result = ensureMultiGet(storage);
111+
const result = (await loadShimWithStorage(storage)).default;
75112

76113
expect(result).not.toBe(storage);
77114
expect('multiGet' in storage).toBe(false);
78115
expect(Object.getPrototypeOf(result)).toBe(storage);
79-
expect(result.getItem).toBe(storage.getItem);
80-
expect(result.setItem).toBe(storage.setItem);
81-
expect(result.removeItem).toBe(storage.removeItem);
82-
expect(result.getAllKeys).toBe(storage.getAllKeys);
83-
expect(result.clear).toBe(storage.clear);
116+
expect(result?.getItem).toBe(storage.getItem);
117+
expect(result?.setItem).toBe(storage.setItem);
118+
expect(result?.removeItem).toBe(storage.removeItem);
119+
expect(result?.getAllKeys).toBe(storage.getAllKeys);
120+
expect(result?.clear).toBe(storage.clear);
84121
});
85122

86123
it('should return pairs in the requested key order regardless of getMany record order', async () => {
@@ -99,8 +136,8 @@ describe('ensureMultiGet', () => {
99136
}),
100137
};
101138

102-
const result = ensureMultiGet(storage);
103-
const pairs = await result.multiGet(['z', 'x', 'y']);
139+
const result = (await loadShimWithStorage(storage)).default;
140+
const pairs = await result?.multiGet(['z', 'x', 'y']);
104141

105142
expect(pairs).toEqual([
106143
['z', 'vz'],

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

Lines changed: 27 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
/**
2-
* Copyright 2024, Optimizely
2+
* Copyright 2024-2026, Optimizely
33
*
44
* Licensed under the Apache License, Version 2.0 (the "License");
55
* you may not use this file except in compliance with the License.
66
* You may obtain a copy of the License at
77
*
8-
* http://www.apache.org/licenses/LICENSE-2.0
8+
* https://www.apache.org/licenses/LICENSE-2.0
99
*
1010
* Unless required by applicable law or agreed to in writing, software
1111
* distributed under the License is distributed on an "AS IS" BASIS,
@@ -14,7 +14,9 @@
1414
* limitations under the License.
1515
*/
1616

17-
import { Platform } from '../../../platform_support';
17+
import { Platform } from '../../platform_support';
18+
19+
// Optional import shim; see ../README.md.
1820

1921
export interface AsyncStorageCompat {
2022
getItem(key: string): Promise<string | null>;
@@ -35,37 +37,43 @@ interface RawAsyncStorage {
3537
getMany?(keys: readonly string[]): Promise<Record<string, string | null>>;
3638
}
3739

38-
export const MODULE_NOT_FOUND_REACT_NATIVE_ASYNC_STORAGE = 'Module not found: @react-native-async-storage/async-storage';
39-
4040
type RawAsyncStorageWithGetMany = RawAsyncStorage & Required<Pick<RawAsyncStorage, 'getMany'>>;
4141

42-
// Returns a wrapper with the original as its prototype, so the shared AsyncStorage object is not mutated.
43-
export const ensureMultiGet = (storage: RawAsyncStorage): AsyncStorageCompat => {
42+
// AsyncStorage v3 replaced multiGet with getMany. Returns a wrapper with the original as its prototype,
43+
// so the shared AsyncStorage object is not mutated.
44+
// multiGet avoids async/await, which would compile to a tslib helper import.
45+
const ensureMultiGet = (storage: RawAsyncStorage): AsyncStorageCompat => {
4446
if (storage.multiGet || !storage.getMany) {
4547
return storage as AsyncStorageCompat;
4648
}
4749

4850
return Object.create(storage, {
4951
multiGet: {
50-
value: async function(
52+
value: function(
5153
this: RawAsyncStorageWithGetMany,
5254
keys: readonly string[],
5355
): Promise<[string, string | null][]> {
54-
const record = await this.getMany(keys);
55-
return keys.map((key: string) => [key, record[key] ?? null] as [string, string | null]);
56+
return this.getMany(keys).then((record) =>
57+
keys.map((key: string) => [key, record[key] ?? null] as [string, string | null])
58+
);
5659
},
5760
},
5861
});
5962
};
6063

61-
export const getDefaultAsyncStorage = (): AsyncStorageCompat => {
62-
try {
63-
// eslint-disable-next-line @typescript-eslint/no-var-requires
64-
const storage = require('@react-native-async-storage/async-storage').default;
65-
return ensureMultiGet(storage);
66-
} catch (e) {
67-
throw new Error(MODULE_NOT_FOUND_REACT_NATIVE_ASYNC_STORAGE);
68-
}
69-
};
64+
export let isAvailable = true;
65+
66+
// The package's default export, with multiGet added when it is missing (v3).
67+
let AsyncStorage: AsyncStorageCompat | undefined;
68+
69+
try {
70+
// eslint-disable-next-line @typescript-eslint/no-var-requires
71+
const storage: RawAsyncStorage | undefined = require('@react-native-async-storage/async-storage').default;
72+
AsyncStorage = storage ? ensureMultiGet(storage) : undefined;
73+
} catch (e) {
74+
isAvailable = false;
75+
}
76+
77+
export default AsyncStorage;
7078

7179
export const __platforms: Platform[] = ['__universal__'];

‎lib/import.react_native/README.md‎

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
# React Native optional import shims
2+
3+
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.
4+
5+
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.
6+
7+
## Why the shims are separate modules
8+
9+
### How Metro connects a `require` to a module
10+
11+
1. **Every file in an app's bundle is a module with a numeric ID.**
12+
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.
13+
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:
14+
15+
```js
16+
// the module's code, as transformed:
17+
_$$_REQUIRE(_dependencyMap[16], "fast-text-encoding")
18+
_$$_REQUIRE(_dependencyMap[17], "react-native-get-random-values")
19+
20+
// the module's definition in the bundle: id 538, then its dependency map
21+
__d(function (...) { /* module code */ }, 538, [76, 27, /* ... */, 541, 547, 556], "…/index.react_native.min.js");
22+
```
23+
24+
### The bug in Metro before 0.82.5
25+
26+
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.
27+
28+
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.
29+
30+
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:
31+
32+
| Index | Dependency |
33+
|---|---|
34+
| 14 | `@react-native-async-storage/async-storage` (optional) |
35+
| 15 | `@react-native-community/netinfo` |
36+
| 16 | `fast-text-encoding` (optional) |
37+
| 17 | `react-native-get-random-values` (optional) |
38+
39+
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:
40+
41+
- **`require("fast-text-encoding")` silently loaded `react-native-get-random-values`.** No error was thrown, no `TextEncoder` was set up, and decisions failed later.
42+
- **`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.
43+
- **A missing async-storage would have shifted netinfo and both polyfill `require`s.**
44+
45+
How `require(undefined)` fails also depends on timing:
46+
- **While a module is loading:** the error is thrown to the caller, so a `try/catch` around the `require` catches it.
47+
- **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.
48+
49+
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.
50+
51+
**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.
52+
53+
### How the shims avoid it
54+
55+
- **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.
56+
- **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.
57+
- **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.
58+
59+
The rules below keep those guarantees.
60+
61+
## Rules for a shim
62+
63+
### One file per optional dependency, named after the package
64+
65+
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.
66+
67+
| Package | Shim |
68+
|---|---|
69+
| `fast-text-encoding` | `fast-text-encoding.ts` |
70+
| `react-native-get-random-values` | `react-native-get-random-values.ts` |
71+
| `@react-native-async-storage/async-storage` | `@react-native-async-storage/async-storage.ts` |
72+
73+
A test goes next to its shim as `<name>.spec.ts`. Spec files are not built as shims.
74+
75+
### Exports: `isAvailable`, plus what the SDK uses from the package
76+
77+
- **`isAvailable`:** `true` if the package loaded, `false` if its `require` threw.
78+
- **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.
79+
- **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.
80+
- **Packages loaded only for side effects:** polyfills like `fast-text-encoding` only need `isAvailable`.
81+
- **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`:
82+
- Shims are built as CommonJS. A default export becomes `exports.default`, and the file is marked with `__esModule`.
83+
- 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.
84+
- `rollup.config.mjs` therefore sets `interop: 'esModule'` for shim imports, so the CJS bundles read `exports.default`.
85+
- 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`.
86+
- **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.
87+
88+
### Exactly one `require` in the built file
89+
90+
The optional `require` must be the only `require` in the built shim (`dist/import.react_native/<name>.js`). Any other `require` in the module could shift when the optional package is missing.
91+
92+
**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.
93+
94+
- **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.
95+
- **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").
96+
- **Type-only imports are fine.** TypeScript removes them, for example `import { Platform } from '../platform_support'`.
97+
- **No other runtime imports, not even of guaranteed packages.** Each of these adds a `require` to the built shim:
98+
- 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.
99+
- SDK code imported by more than one shim goes into a shared file that each of them `require`s.
100+
101+
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.
102+
- **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.
103+
- **Export `__platforms`,** as every file in `lib/` does for the platform-isolation check.
104+
105+
## Template
106+
107+
```ts
108+
import { Platform } from '../platform_support';
109+
110+
export let isAvailable = true;
111+
112+
// Only the exports the SDK uses, under the package's names.
113+
export let someExport: SomeType | undefined;
114+
115+
try {
116+
// eslint-disable-next-line @typescript-eslint/no-var-requires
117+
someExport = require('some-package').someExport;
118+
} catch (e) {
119+
isAvailable = false;
120+
}
121+
122+
export const __platforms: Platform[] = ['react_native'];
123+
```
124+
125+
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.

0 commit comments

Comments
 (0)