diff --git a/docs/docs/01-fundamentals/01-getting-started.md b/docs/docs/01-fundamentals/01-getting-started.md
index 9a93c66385..461d81c04a 100644
--- a/docs/docs/01-fundamentals/01-getting-started.md
+++ b/docs/docs/01-fundamentals/01-getting-started.md
@@ -86,10 +86,12 @@ React Native ExecuTorch requires:
- **New Architecture** enabled
- **React Native 0.83+** or **Expo SDK 55+** with [Development Builds](https://docs.expo.dev/develop/development-builds/introduction/) (**Expo Go is not supported** due to custom C++ native libraries)
- **`react-native-worklets` 0.10 or newer** (`>=0.10.0 <0.13.0`)
-- **iOS 17.0+** / **Android 13+**
+- **iOS 17.0+** / **Android 13+** (`minSdkVersion` >= 26)
For supported React Native versions, see the [Compatibility
-table](../05-other/01-compatibility.mdx).
+table](../05-other/01-compatibility.mdx). If an install or a build fails, see
+[Troubleshooting](../05-other/02-troubleshooting.md) — most of it comes down to
+the package manager skipping the postinstall hook, or to `use_frameworks!`.
:::
:::caution Expo SDK 55 and 56
diff --git a/docs/docs/03-core-and-advanced/08-native-libraries.md b/docs/docs/03-core-and-advanced/08-native-libraries.md
index 63b20c4198..22bee7ea79 100644
--- a/docs/docs/03-core-and-advanced/08-native-libraries.md
+++ b/docs/docs/03-core-and-advanced/08-native-libraries.md
@@ -47,6 +47,22 @@ Add a `react-native-executorch` block to your `package.json`:
The three lists are merged, so you can pair high-level `features` with specific `backends` or `libs`. Re-run your package manager install after editing.
+:::note Monorepos
+The block is read from the directory the install was run in, then from every
+`package.json` above the installed package. A hoisted workspace resolves to the
+**root** either way, so put the block there; an app that keeps its own
+`node_modules` (pnpm, nohoist) is found from its own `package.json`.
+`node_modules/react-native-executorch/rne-build-config.json` records what was
+actually resolved, and the install log names the manifest it read.
+:::
+
+:::caution pnpm
+pnpm 10 and later do not run dependency build scripts unless you allow them, so
+the download never happens and the native build fails later on a missing file.
+Run `pnpm approve-builds react-native-executorch` once. See
+[Troubleshooting](../05-other/02-troubleshooting.md).
+:::
+
### Backends
Hardware backends provide optimized execution kernels for specific processors and platforms. See the [ExecuTorch Backends documentation](https://docs.pytorch.org/executorch/stable/backends-section.html) for details on lowering and delegate compilation:
diff --git a/docs/docs/05-other/02-troubleshooting.md b/docs/docs/05-other/02-troubleshooting.md
new file mode 100644
index 0000000000..f5fc608a3b
--- /dev/null
+++ b/docs/docs/05-other/02-troubleshooting.md
@@ -0,0 +1,166 @@
+---
+title: Troubleshooting
+slug: /other/troubleshooting
+description: 'Build and install failures caused by a project setup React Native ExecuTorch cannot control, and what to do about each.'
+keywords:
+ [react native executorch, troubleshooting, use_frameworks, pnpm, opencv, cocoapods, simulator]
+---
+
+Failures that come from how a project is set up rather than from the library
+itself. Each one is reproducible, so the symptom is quoted exactly — search
+this page for the error you got.
+
+## `Build input files cannot be found` after install
+
+```
+error: Build input files cannot be found:
+'.../XnnpackBackend.xcframework/ios-arm64-simulator/libXnnpackBackend.a'
+```
+
+The native artifacts are not in the npm tarball. They are downloaded by a
+**postinstall** hook, and your package manager skipped it:
+
+- **pnpm 10 and later block dependency build scripts by default** and print
+ `Ignored build scripts: react-native-executorch` during install.
+- `--ignore-scripts`, and `npm ci --ignore-scripts`, do the same.
+
+Nothing fails at that point — not even `pod install`, which does not check that
+a vendored framework exists — so the error only appears once Xcode goes looking
+for the file. Recent versions fail during `pod install` (and during the Android
+configure phase) with this fix in the message instead. Re-run the hook:
+
+```bash
+pnpm approve-builds react-native-executorch # pnpm
+npm rebuild react-native-executorch # npm
+node node_modules/react-native-executorch/scripts/download-libs.js
+```
+
+## `Multiple commands produce .../Headers/Types.h`
+
+An app built with `use_frameworks!` — directly, as Firebase requires, or through
+expo-build-properties' `"useFrameworks": "static"` — makes CocoaPods build the
+pod as a framework and flat-copy its public headers into one directory.
+Versions up to 0.10.0 published every header, and several share a basename, so
+the build fails while it is still being planned.
+
+Update the library. On 0.10.0 exactly, force the pod back to a static library:
+
+```ruby
+pre_install do |installer|
+ installer.pod_targets.each do |pod|
+ if pod.name == 'react-native-executorch'
+ def pod.build_type
+ Pod::BuildType.static_library
+ end
+ end
+ end
+end
+```
+
+Expo SDK 55 and later already do this for you: their autolinking downgrades
+every pod that vendors an `.xcframework` to a static library, which is why an
+Expo app usually never sees this. Setting `buildReactNativeFromSource: true`
+turns that off again.
+
+## `transitive dependencies that include statically linked binaries`
+
+```
+[!] The 'Pods-YourApp' target has transitive dependencies that include
+statically linked binaries: (.../opencv-rne/opencv2.xcframework)
+```
+
+`use_frameworks!` with no argument means **dynamic** linkage, which CocoaPods
+refuses to combine with a statically linked dependency. Ask for static
+frameworks instead:
+
+```ruby
+use_frameworks! :linkage => :static
+```
+
+## `frameworks with conflicting names: opencv2.xcframework`
+
+Another pod vendors OpenCV under the same framework name — `react-native-fast-opencv`
+(via `FastOpenCV-iOS`) is the common one — and CocoaPods installs only one
+framework called `opencv2`.
+
+Recent versions handle this for you: when `react-native-fast-opencv` is
+installed alongside this library, we depend on the OpenCV it vendors instead of
+our own, and compile against that copy's headers. `pod install` prints which one
+it chose. Nothing to configure, and both libraries work in the same app.
+
+To force the choice, name the pod that should provide OpenCV:
+
+```json
+{
+ "react-native-executorch": {
+ "opencvPod": "opencv-rne"
+ }
+}
+```
+
+`opencv-rne` is ours; any pod that vendors an `opencv2.xcframework` is accepted.
+We only use `opencv2/core.hpp` and `opencv2/imgproc.hpp`, so an OpenCV 4.x build
+serves. Forcing ours while another OpenCV is installed brings the conflict back,
+which is what the setting is for when you would rather drop the other library.
+
+If you do not use this library's vision tasks at all, drop its OpenCV instead:
+
+```json
+{
+ "react-native-executorch": {
+ "backends": ["xnnpack", "coreml", "mlx"],
+ "libs": ["phonemis"]
+ }
+}
+```
+
+Re-run your package manager's install afterwards. See
+[Native Libraries](../03-core-and-advanced/08-native-libraries.md) for what each
+entry covers — leaving `opencv` out disables every computer-vision task.
+
+## `None of the architectures in ARCHS (x86_64) are valid`
+
+The library ships `arm64` slices only, and the podspec excludes `x86_64` from
+simulator builds. **Intel Macs cannot build for the iOS simulator**, and neither
+can an Apple silicon Mac running Xcode under Rosetta, or an Intel macOS CI
+image. Use an Apple silicon machine, or a physical device.
+
+## `The platform of the target ... may not be compatible`
+
+```
+[!] The platform of the target `YourApp` (iOS 16.4) may not be compatible with
+`react-native-executorch (0.10.0)` which has a minimum requirement of iOS 17.0.
+```
+
+This is a **warning**, so `pod install` still succeeds and the failure surfaces
+later. The library needs iOS 17. In an Expo app, set it explicitly — the default
+is lower:
+
+```json
+[
+ "expo-build-properties",
+ { "ios": { "deploymentTarget": "17.0" } }
+]
+```
+
+## The `react-native-executorch` config block is ignored
+
+The postinstall hook reads the block from the directory where the install was
+invoked (`INIT_CWD`), then from every `package.json` above the installed
+package. In a hoisted monorepo both land on the **workspace root**, so a block
+in `apps/mobile/package.json` is never seen — put it in the root `package.json`
+instead. The install log names the manifest that won, and
+`node_modules/react-native-executorch/rne-build-config.json` records which flags
+were written.
+
+## An old Android device or emulator crashes on load
+
+Native code is shipped for `arm64-v8a` and `x86_64` only. A build that also
+produces `armeabi-v7a` or `x86` splits (React Native's default
+`reactNativeArchitectures` lists all four) will package those without the
+library's `.so`, and loading it fails at runtime on such a device. Restrict the
+app to the supported ABIs:
+
+```properties
+reactNativeArchitectures=arm64-v8a,x86_64
+```
diff --git a/docs/versioned_docs/version-0.10.0/01-fundamentals/01-getting-started.md b/docs/versioned_docs/version-0.10.0/01-fundamentals/01-getting-started.md
index bfc9a94e42..4e7931c857 100644
--- a/docs/versioned_docs/version-0.10.0/01-fundamentals/01-getting-started.md
+++ b/docs/versioned_docs/version-0.10.0/01-fundamentals/01-getting-started.md
@@ -84,11 +84,30 @@ pnpm add react-native-executorch react-native-worklets react-native-blob-util
React Native ExecuTorch requires:
- **New Architecture** enabled
-- **React Native 0.81+** or **Expo SDK 54+** with [Development Builds](https://docs.expo.dev/develop/development-builds/introduction/) (**Expo Go is not supported** due to custom C++ native libraries)
-- **iOS 17.0+** / **Android 13+**
+- **React Native 0.83+** or **Expo SDK 55+** with [Development Builds](https://docs.expo.dev/develop/development-builds/introduction/) (**Expo Go is not supported** due to custom C++ native libraries)
+- **`react-native-worklets` 0.10 or newer** (`>=0.10.0 <0.13.0`)
+- **iOS 17.0+** / **Android 13+** (set the app's `minSdkVersion` to 26 or higher)
For supported React Native versions, see the [Compatibility
-table](../05-other/01-compatibility.mdx).
+table](../05-other/01-compatibility.mdx). If an install or a build fails, see
+[Troubleshooting](../05-other/02-troubleshooting.md) — most of it comes down to
+the package manager skipping the postinstall hook, or to `use_frameworks!`.
+:::
+
+:::caution Expo SDK 55 and 56
+Both bundle a `react-native-worklets` older than 0.10 — 0.7.4 on SDK 55, 0.8.3
+on SDK 56 — and `npx expo install` will pick that one. Install the version this
+library needs instead:
+
+```bash
+npm install react-native-worklets@^0.10.0
+```
+
+Any other package in your app that uses worklets has to be moved to a release
+built against the same version; two of them asking for different worklets gives
+you two incompatible native runtimes. Our own example apps reconcile them this
+way. **Expo SDK 54 cannot be supported**: it is React Native 0.81, below what
+worklets 0.10 accepts.
:::
### Selecting native libraries
diff --git a/docs/versioned_docs/version-0.10.0/03-core-and-advanced/08-native-libraries.md b/docs/versioned_docs/version-0.10.0/03-core-and-advanced/08-native-libraries.md
index 63b20c4198..ce9f76c506 100644
--- a/docs/versioned_docs/version-0.10.0/03-core-and-advanced/08-native-libraries.md
+++ b/docs/versioned_docs/version-0.10.0/03-core-and-advanced/08-native-libraries.md
@@ -47,6 +47,20 @@ Add a `react-native-executorch` block to your `package.json`:
The three lists are merged, so you can pair high-level `features` with specific `backends` or `libs`. Re-run your package manager install after editing.
+:::note Monorepos
+The block is read from the directory the install was run in, which in a
+workspace is the **root** — a block in an app's own `package.json` is ignored.
+`node_modules/react-native-executorch/rne-build-config.json` records what was
+actually resolved.
+:::
+
+:::caution pnpm
+pnpm 10 and later do not run dependency build scripts unless you allow them, so
+the download never happens and the native build fails later on a missing file.
+Run `pnpm approve-builds react-native-executorch` once. See
+[Troubleshooting](../05-other/02-troubleshooting.md).
+:::
+
### Backends
Hardware backends provide optimized execution kernels for specific processors and platforms. See the [ExecuTorch Backends documentation](https://docs.pytorch.org/executorch/stable/backends-section.html) for details on lowering and delegate compilation:
diff --git a/docs/versioned_docs/version-0.10.0/05-other/01-compatibility.mdx b/docs/versioned_docs/version-0.10.0/05-other/01-compatibility.mdx
index 31c84ddd62..e99fdbb98a 100644
--- a/docs/versioned_docs/version-0.10.0/05-other/01-compatibility.mdx
+++ b/docs/versioned_docs/version-0.10.0/05-other/01-compatibility.mdx
@@ -17,7 +17,7 @@ React Native ExecuTorch supports only the [New Architecture](https://reactnative
| React Native ExecuTorch |
- React Native version |
+ React Native version |
| 0.78 |
@@ -28,6 +28,7 @@ React Native ExecuTorch supports only the [New Architecture](https://reactnative
0.83 |
0.84 |
0.85 |
+ 0.86 |
@@ -41,6 +42,7 @@ React Native ExecuTorch supports only the [New Architecture](https://reactnative
yes |
yes |
yes |
+ untested† |
0.9.x |
@@ -52,18 +54,94 @@ React Native ExecuTorch supports only the [New Architecture](https://reactnative
yes |
yes |
yes |
+ untested† |
0.10.x |
no |
no |
no |
+ no* |
+ no* |
yes |
yes |
yes |
yes |
+
+
+
+
+
+
+
+**\*** `react-native-executorch` 0.10 needs `react-native-worklets`
+`>=0.10.0 <0.13.0`, and worklets 0.10 is the first release to serialize an
+`ArrayBufferView` natively ([reanimated
+#9475](https://github.com/software-mansion/react-native-reanimated/pull/9475));
+older ones rebuild the view over its whole backing buffer, losing `byteOffset`
+and `length`. Worklets 0.10 in turn requires React Native 0.83+, which is what
+rules out 0.81 and 0.82.
+
+**†** Not verified. 0.8.x and 0.9.x were released before React Native 0.86 and
+are maintained on the `legacy` dist-tag only.
+
+
+
+## Expo SDK
+
+`npx expo install` picks the `react-native-worklets` an SDK bundles, which is
+older than this library needs on SDK 55 and 56. Both work once you ask for the
+versions below explicitly — Reanimated pins worklets exactly, so it moves with
+it.
+
+
+
+
+
+ | Expo SDK |
+ React Native |
+ Bundled worklets |
+ react-native-executorch 0.10.x |
+
+
+
+
+ 54 |
+ 0.81 |
+ 0.5.1 |
+ no |
+
+
+ 55 |
+ 0.83 |
+ 0.7.4 |
+ needs explicit versions |
+
+
+ 56 |
+ 0.85 |
+ 0.8.3 |
+ needs explicit versions |
+
+
+ 57 |
+ 0.86 |
+ 0.10.1 |
yes |
+
+
+
+On SDK 55 and 56, install both:
+
+```bash
+npm install react-native-worklets@^0.10.0 react-native-reanimated@^4.5.0
+```
+
+SDK 54 is React Native 0.81, below what worklets 0.10 accepts, so no
+combination of versions works there.
+
+
diff --git a/docs/versioned_docs/version-0.10.0/05-other/02-troubleshooting.md b/docs/versioned_docs/version-0.10.0/05-other/02-troubleshooting.md
new file mode 100644
index 0000000000..4a192182ae
--- /dev/null
+++ b/docs/versioned_docs/version-0.10.0/05-other/02-troubleshooting.md
@@ -0,0 +1,155 @@
+---
+title: Troubleshooting
+slug: /other/troubleshooting
+description: 'Build and install failures caused by a project setup React Native ExecuTorch cannot control, and what to do about each.'
+keywords:
+ [react native executorch, troubleshooting, use_frameworks, pnpm, opencv, cocoapods, simulator]
+---
+
+Failures that come from how a project is set up rather than from the library
+itself. Each one is reproducible against `0.10.0`, so the symptom is quoted
+exactly — search this page for the error you got.
+
+## `Build input files cannot be found` after install
+
+```
+error: Build input files cannot be found:
+'.../XnnpackBackend.xcframework/ios-arm64-simulator/libXnnpackBackend.a'
+```
+
+The native artifacts are not in the npm tarball. They are downloaded by a
+**postinstall** hook, and your package manager skipped it:
+
+- **pnpm 10 and later block dependency build scripts by default** and print
+ `Ignored build scripts: react-native-executorch` during install.
+- `--ignore-scripts`, and `npm ci --ignore-scripts`, do the same.
+
+Nothing fails at that point — not even `pod install`, which does not check that
+a vendored framework exists — so the error only appears once Xcode goes looking
+for the file. Re-run the hook:
+
+```bash
+pnpm approve-builds react-native-executorch # pnpm
+npm rebuild react-native-executorch # npm
+node node_modules/react-native-executorch/scripts/download-libs.js
+```
+
+## `Multiple commands produce .../Headers/Types.h`
+
+An app built with `use_frameworks!` — directly, as Firebase requires, or through
+expo-build-properties' `"useFrameworks": "static"` — makes CocoaPods build the
+pod as a framework and flat-copy its public headers into one directory. `0.10.0`
+publishes every header, and several share a basename, so the build fails while
+it is still being planned.
+
+Force the pod back to a static library in your `Podfile`:
+
+```ruby
+pre_install do |installer|
+ installer.pod_targets.each do |pod|
+ if pod.name == 'react-native-executorch'
+ def pod.build_type
+ Pod::BuildType.static_library
+ end
+ end
+ end
+end
+```
+
+Expo SDK 55 and later already do this for you: their autolinking downgrades
+every pod that vendors an `.xcframework` to a static library, which is why an
+Expo app usually never sees this. Setting `buildReactNativeFromSource: true`
+turns that off again.
+
+## `transitive dependencies that include statically linked binaries`
+
+```
+[!] The 'Pods-YourApp' target has transitive dependencies that include
+statically linked binaries: (.../opencv-rne/opencv2.xcframework)
+```
+
+`use_frameworks!` with no argument means **dynamic** linkage, which CocoaPods
+refuses to combine with a statically linked dependency. Ask for static
+frameworks instead:
+
+```ruby
+use_frameworks! :linkage => :static
+```
+
+## `frameworks with conflicting names: opencv2.xcframework`
+
+Another pod vendors OpenCV under the same framework name — `react-native-fast-opencv`
+(via `FastOpenCV-iOS`) is the common one — and CocoaPods installs only one
+framework called `opencv2`. On `0.10.0` the two cannot be installed together.
+
+If you do not use this library's vision tasks, drop its OpenCV:
+
+```json
+{
+ "react-native-executorch": {
+ "backends": ["xnnpack", "coreml", "mlx"],
+ "libs": ["phonemis"]
+ }
+}
+```
+
+Re-run your package manager's install afterwards. See
+[Native Libraries](../03-core-and-advanced/08-native-libraries.md) for what each
+entry covers — leaving `opencv` out disables every computer-vision task.
+
+## `None of the architectures in ARCHS (x86_64) are valid`
+
+The library ships `arm64` slices only, and the podspec excludes `x86_64` from
+simulator builds. **Intel Macs cannot build for the iOS simulator**, and neither
+can an Apple silicon Mac running Xcode under Rosetta, or an Intel macOS CI
+image. Use an Apple silicon machine, or a physical device.
+
+## `The platform of the target ... may not be compatible`
+
+```
+[!] The platform of the target `YourApp` (iOS 16.4) may not be compatible with
+`react-native-executorch (0.10.0)` which has a minimum requirement of iOS 17.0.
+```
+
+This is a **warning**, so `pod install` still succeeds and the failure surfaces
+later. The library needs iOS 17. In an Expo app, set it explicitly — the default
+is lower:
+
+```json
+[
+ "expo-build-properties",
+ { "ios": { "deploymentTarget": "17.0" } }
+]
+```
+
+## The `react-native-executorch` config block is ignored
+
+The postinstall hook reads the block from the directory where the install was
+invoked (`INIT_CWD`). In a monorepo that is the **workspace root**, so a block
+in `apps/mobile/package.json` is never seen — put it in the root
+`package.json` instead. Check `node_modules/react-native-executorch/rne-build-config.json`
+after installing to confirm which flags were written.
+
+## An old Android device or emulator crashes on load
+
+Two separate causes, and both end in the same runtime failure.
+
+The shipped `.so` files are built against **API 26**, while `0.10.0` declares
+`minSdkVersion 21`, so nothing stops a lower-API build. Raise it in your app's
+`android/build.gradle`:
+
+```groovy
+ext {
+ minSdkVersion = 26
+}
+```
+
+Native code is also shipped for `arm64-v8a` and `x86_64` only. A build that
+produces `armeabi-v7a` or `x86` splits (React Native's default
+`reactNativeArchitectures` lists all four) will package those without the
+library's `.so`, and loading it fails at runtime on such a device. Restrict the
+app to the supported ABIs in `android/gradle.properties`:
+
+```properties
+reactNativeArchitectures=arm64-v8a,x86_64
+```
diff --git a/packages/react-native-executorch/.gitignore b/packages/react-native-executorch/.gitignore
index 7a7c31fe50..928071e363 100644
--- a/packages/react-native-executorch/.gitignore
+++ b/packages/react-native-executorch/.gitignore
@@ -14,3 +14,4 @@ cpp/tests/build/
# Model and tokenizer fixtures downloaded by scripts/fetch-test-fixtures.sh
cpp/tests/fixtures/
+include-external-opencv
diff --git a/packages/react-native-executorch/__tests__/api/nativeLibsConfig.test.ts b/packages/react-native-executorch/__tests__/api/nativeLibsConfig.test.ts
new file mode 100644
index 0000000000..3b8d2dbf8c
--- /dev/null
+++ b/packages/react-native-executorch/__tests__/api/nativeLibsConfig.test.ts
@@ -0,0 +1,149 @@
+/**
+ * Where `scripts/download-libs.js` looks for the `react-native-executorch`
+ * block.
+ *
+ * The block decides which native artifacts get downloaded, and it is read at
+ * install time from `INIT_CWD` — the directory the install was invoked in. In a
+ * workspace that is the repository root, so a block in `apps/mobile/package.json`
+ * used to be ignored silently: the install succeeded, every backend came down,
+ * and the app was simply larger than asked for. Worse, an app declaring
+ * `"libs": []` to dodge an OpenCV conflict got OpenCV anyway.
+ *
+ * So the lookup also walks up from the installed package, which lands on the app
+ * whenever it has its own `node_modules` (pnpm, nohoist). These suites pin both
+ * paths, and the precedence between them.
+ */
+import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'fs';
+import { tmpdir } from 'os';
+import { join } from 'path';
+
+const SCRIPT = '../../scripts/download-libs.js';
+
+/** npm's own fallback for `INIT_CWD`, spelled the way npm spells it. */
+const LOCAL_PREFIX = 'npm_config_local_prefix';
+
+/**
+ * Loads the script with `INIT_CWD` pointing at `initCwd`.
+ *
+ * The variable stays set until `afterEach` restores it: the lookup reads it
+ * when it is called, not when the module is loaded.
+ */
+function loadWith(initCwd: string | undefined) {
+ if (initCwd === undefined) delete process.env.INIT_CWD;
+ else process.env.INIT_CWD = initCwd;
+ jest.resetModules();
+ return require(SCRIPT);
+}
+
+function writeManifest(dir: string, block?: unknown) {
+ mkdirSync(dir, { recursive: true });
+ writeFileSync(
+ join(dir, 'package.json'),
+ JSON.stringify(
+ block === undefined ? { name: 'app' } : { 'name': 'app', 'react-native-executorch': block },
+ null,
+ 2
+ )
+ );
+}
+
+describe('native libs user config', () => {
+ let root: string;
+ let outerInitCwd: string | undefined;
+ let outerLocalPrefix: string | undefined;
+
+ beforeEach(() => {
+ root = mkdtempSync(join(tmpdir(), 'rne-config-'));
+ outerInitCwd = process.env.INIT_CWD;
+ outerLocalPrefix = process.env[LOCAL_PREFIX];
+ // npm_config_local_prefix is the fallback for INIT_CWD; a stray one from the
+ // outer yarn process would leak into every case.
+ delete process.env[LOCAL_PREFIX];
+ });
+
+ afterEach(() => {
+ rmSync(root, { recursive: true, force: true });
+ if (outerInitCwd === undefined) delete process.env.INIT_CWD;
+ else process.env.INIT_CWD = outerInitCwd;
+ if (outerLocalPrefix === undefined) delete process.env[LOCAL_PREFIX];
+ else process.env[LOCAL_PREFIX] = outerLocalPrefix;
+ });
+
+ it('reads the block from the directory the install ran in', () => {
+ writeManifest(root, { backends: ['xnnpack'], libs: [] });
+ const { findUserConfig } = loadWith(root);
+
+ const { config, manifest } = findUserConfig();
+
+ expect(config).toEqual({ backends: ['xnnpack'], libs: [] });
+ expect(manifest).toBe(join(root, 'package.json'));
+ });
+
+ it('prefers the install directory over anything above the package', () => {
+ writeManifest(root, { backends: ['coreml'] });
+ const { findUserConfig } = loadWith(root);
+
+ expect(findUserConfig().config).toEqual({ backends: ['coreml'] });
+ });
+
+ it('reports no block when the manifest has none', () => {
+ writeManifest(root);
+ const { findUserConfig } = loadWith(root);
+
+ expect(findUserConfig().config).toBeUndefined();
+ });
+
+ it('falls back to enabling everything when there is no block', () => {
+ writeManifest(root);
+ const { readUserConfig, ALL_BACKENDS, ALL_LIBS } = loadWith(root);
+
+ const resolved = readUserConfig();
+
+ expect(new Set(resolved.backends)).toEqual(new Set(ALL_BACKENDS));
+ expect(new Set(resolved.libs)).toEqual(new Set(ALL_LIBS));
+ });
+
+ it('survives an unreadable manifest', () => {
+ mkdirSync(root, { recursive: true });
+ writeFileSync(join(root, 'package.json'), '{ not json');
+ const { findUserConfig } = loadWith(root);
+
+ expect(() => findUserConfig()).not.toThrow();
+ });
+
+ it('expands features into backends and libs', () => {
+ writeManifest(root, { features: ['textToSpeech'] });
+ const { readUserConfig } = loadWith(root);
+
+ const resolved = readUserConfig();
+
+ expect(resolved.libs).toContain('phonemis');
+ expect(resolved.backends).toContain('xnnpack');
+ });
+
+ it('rejects an unknown feature by name', () => {
+ writeManifest(root, { features: ['telepathy'] });
+ const { readUserConfig } = loadWith(root);
+
+ expect(() => readUserConfig()).toThrow(/telepathy/);
+ });
+
+ it('carries opencvPod through to the build config the podspec reads', () => {
+ // Which pod provides opencv2 is the app's call when it already carries an
+ // OpenCV: the podspec reads this key, and it only reaches the podspec if
+ // the config script writes it out.
+ writeManifest(root, { opencvPod: 'FastOpenCV-iOS' });
+ const { readUserConfig } = loadWith(root);
+
+ expect(readUserConfig().opencvPod).toBe('FastOpenCV-iOS');
+ });
+
+ it('leaves opencvPod unset when the app does not ask for one', () => {
+ // Absent means "decide for me", which is what lets the podspec prefer an
+ // OpenCV the app already has. Writing a default here would freeze that.
+ writeManifest(root, { backends: ['xnnpack'] });
+ const { readUserConfig } = loadWith(root);
+
+ expect(readUserConfig().opencvPod).toBeUndefined();
+ });
+});
diff --git a/packages/react-native-executorch/android/build.gradle.kts b/packages/react-native-executorch/android/build.gradle.kts
index 01d75e11be..427227a6bf 100644
--- a/packages/react-native-executorch/android/build.gradle.kts
+++ b/packages/react-native-executorch/android/build.gradle.kts
@@ -53,6 +53,44 @@ fun rneBuildConfig(): Map<*, *> {
val rneConfig = rneBuildConfig()
fun rneFlag(key: String): String = if (rneConfig[key] != false) "ON" else "OFF"
+/**
+ * The prebuilt ExecuTorch runtime is not in the npm tarball - `package.json`
+ * excludes it and `scripts/download-libs.js` fetches it from the matching
+ * GitHub release in a postinstall hook. When a package manager skips that hook
+ * the install still looks clean and the failure surfaces much later, out of
+ * CMake, blamed on a missing library rather than on the install. Fail here with
+ * the fix instead.
+ */
+fun requireNativeArtifacts() {
+ val libsDir = file("../third-party/android/libs/executorch")
+ val present = libsDir.listFiles()
+ ?.filter { it.isDirectory && it.resolve("libexecutorch.so").exists() }
+ .orEmpty()
+ if (present.isNotEmpty()) return
+
+ throw GradleException(
+ """
+ react-native-executorch is missing its native artifacts:
+
+ ${libsDir.absolutePath}
+
+ They are downloaded by this package's postinstall hook, which your
+ package manager did not run. pnpm 10 and later block dependency build
+ scripts by default ("Ignored build scripts"), and so do
+ `--ignore-scripts` and `npm ci --ignore-scripts`. Re-run the hook:
+
+ pnpm approve-builds react-native-executorch # pnpm
+ npm rebuild react-native-executorch # npm
+ node node_modules/react-native-executorch/scripts/download-libs.js
+
+ If you provision the libraries yourself, put them under
+ third-party/android/libs/executorch// before building.
+ """.trimIndent()
+ )
+}
+
+requireNativeArtifacts()
+
/**
* ExecuTorch only supports these ABIs. Honor the app's `reactNativeArchitectures`
* (e.g. Expo passes `-PreactNativeArchitectures=arm64-v8a` for device builds) so
@@ -70,7 +108,12 @@ android {
compileSdk = (getExtOrDefault("compileSdkVersion", 34) as Number).toInt()
defaultConfig {
- minSdk = (getExtOrDefault("minSdkVersion", 21) as Number).toInt()
+ // The prebuilt ExecuTorch runtime is compiled against Android API 26 -
+ // `.note.android.ident` in libexecutorch.so, libxnnpack_executorch_backend.so
+ // and libvulkan_executorch_backend.so all read 26 - so an app below that
+ // ships a library its linker is not guaranteed to be able to load. The
+ // default was 21, which let such a build through to fail on the device.
+ minSdk = (getExtOrDefault("minSdkVersion", 26) as Number).toInt()
targetSdk = (getExtOrDefault("targetSdkVersion", 34) as Number).toInt()
consumerProguardFiles("consumer-proguard-rules.pro")
diff --git a/packages/react-native-executorch/react-native-executorch.podspec b/packages/react-native-executorch/react-native-executorch.podspec
index cb9410c682..8b5d6e6503 100644
--- a/packages/react-native-executorch/react-native-executorch.podspec
+++ b/packages/react-native-executorch/react-native-executorch.podspec
@@ -1,3 +1,4 @@
+require "fileutils"
require "json"
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
@@ -21,6 +22,49 @@ else
enable_mlx = true
end
+# The native artifacts are not in the npm tarball - `package.json` excludes
+# them and `scripts/download-libs.js` fetches them from the matching GitHub
+# release in a postinstall hook. When a package manager skips that hook the
+# install still looks clean, and so does `pod install`: CocoaPods never checks
+# that a vendored framework exists. The build then fails minutes later with
+#
+# error: Build input files cannot be found:
+# '.../XnnpackBackend.xcframework/ios-arm64-simulator/libXnnpackBackend.a'
+#
+# which names neither the cause nor the fix. Fail here instead, while the user
+# is still looking at the install that caused it.
+required_artifacts = { "ExecutorchLib.xcframework" => true }
+required_artifacts["XnnpackBackend.xcframework"] = enable_xnnpack
+required_artifacts["CoreMLBackend.xcframework"] = enable_coreml
+required_artifacts["MLXBackend.xcframework"] = enable_mlx
+
+missing = required_artifacts
+ .select { |_, required| required }
+ .keys
+ .map { |name| File.join(__dir__, "third-party/ios", name) }
+ .reject { |path| File.directory?(path) }
+
+unless missing.empty?
+ # Pod::Informative renders as a plain `[!]` message rather than a backtrace.
+ raise(defined?(Pod::Informative) ? Pod::Informative : StandardError, <<~MESSAGE)
+ react-native-executorch is missing its native artifacts:
+
+ #{missing.map { |path| " #{path}" }.join("\n")}
+
+ They are downloaded by this package's postinstall hook, which your package
+ manager did not run. pnpm 10 and later block dependency build scripts by
+ default ("Ignored build scripts"), and so do `--ignore-scripts` and
+ `npm ci --ignore-scripts`. Re-run the hook, then `pod install` again:
+
+ pnpm approve-builds react-native-executorch # pnpm
+ npm rebuild react-native-executorch # npm
+ node node_modules/react-native-executorch/scripts/download-libs.js
+
+ If you provision the libraries yourself, put them under
+ third-party/ios/ before installing pods.
+ MESSAGE
+end
+
Pod::Spec.new do |s|
s.name = "react-native-executorch"
s.version = package["version"]
@@ -171,6 +215,63 @@ Pod::Spec.new do |s|
'EXCLUDED_ARCHS[sdk=iphonesimulator*]' => 'x86_64',
}
+ # iOS OpenCV is provided by a CocoaPod (not a downloaded tarball), normally
+ # our own opencv-rne.
+ #
+ # An app can already carry OpenCV through another library, and CocoaPods
+ # refuses to install two vendored frameworks with the same name:
+ #
+ # [!] The 'Pods-YourApp' target has frameworks with conflicting names:
+ # opencv2.xcframework
+ #
+ # react-native-fast-opencv is the one this happens with, and wanting both is
+ # reasonable: our inference API with their image transformations. So when it
+ # is installed alongside us we depend on the pod it vendors instead of our
+ # own, which leaves exactly one opencv2 in the project. We only use
+ # `opencv2/core.hpp` and `opencv2/imgproc.hpp`, so any OpenCV 4.x build
+ # serves. Set "opencvPod" in the package.json config block to override the
+ # choice in either direction.
+ external_opencv = false
+ if enable_opencv
+ detected_opencv_pod =
+ if Dir.exist?(File.join(__dir__, "..", "react-native-fast-opencv"))
+ "FastOpenCV-iOS"
+ else
+ "opencv-rne"
+ end
+ opencv_pod = rne_build_config["opencvPod"] || detected_opencv_pod
+
+ if opencv_pod == "opencv-rne"
+ s.dependency "opencv-rne", "~> 4.11.0"
+ else
+ Pod::UI.puts "[react-native-executorch] using #{opencv_pod} for OpenCV " \
+ "instead of opencv-rne, so the project holds one opencv2" if defined?(Pod::UI)
+ s.dependency opencv_pod
+ external_opencv = true
+ end
+
+ # Our own OpenCV headers ship under third-party/include and are newer than
+ # what another OpenCV pod vendors: react-native-fast-opencv carries 4.9, and
+ # compiling against ours while linking against theirs fails at link time on
+ # any signature that moved since (cvtColor gained an AlgorithmHint parameter
+ # in 4.10). Whoever provides the binary has to provide the headers, so with
+ # an external OpenCV the include root becomes a mirror of ours with opencv2
+ # left out, and `#include ` resolves through their framework.
+ mirror_without_opencv = lambda do
+ source = File.join(__dir__, "third-party/include")
+ mirror = File.join(__dir__, "third-party/include-external-opencv")
+ FileUtils.rm_rf(mirror)
+ FileUtils.mkdir_p(mirror)
+ Dir.children(source).each do |entry|
+ next if entry == "opencv2"
+ FileUtils.ln_s(File.join(source, entry), File.join(mirror, entry))
+ end
+ "third-party/include-external-opencv"
+ end
+
+ third_party_include =
+ external_opencv ? mirror_without_opencv.call : "third-party/include"
+
s.pod_target_xcconfig = {
"USE_HEADERMAP" => "YES",
"CLANG_CXX_LANGUAGE_STANDARD" => "c++20",
@@ -186,7 +287,7 @@ Pod::Spec.new do |s|
# ==============================================================================
"\"$(PODS_TARGET_SRCROOT)/legacy/cpp\"",
# ==============================================================================
- "\"$(PODS_TARGET_SRCROOT)/third-party/include\"",
+ "\"$(PODS_TARGET_SRCROOT)/#{third_party_include}\"",
"\"$(PODS_TARGET_SRCROOT)/third-party/include/cpuinfo\"",
"\"$(PODS_TARGET_SRCROOT)/third-party/include/pthreadpool\"",
"\"$(PODS_TARGET_SRCROOT)/third-party/include/executorch/extension/llm/tokenizers/include\"",
@@ -235,8 +336,7 @@ Pod::Spec.new do |s|
# ExecutorchLib goes in vendored_frameworks to avoid duplicate symbol errors.
s.ios.vendored_frameworks = ["third-party/ios/ExecutorchLib.xcframework"]
- # iOS OpenCV is provided by the opencv-rne CocoaPod (not a downloaded tarball).
- s.dependency "opencv-rne", "~> 4.11.0" if enable_opencv
+ end
install_modules_dependencies(s)
end
diff --git a/packages/react-native-executorch/scripts/download-libs.js b/packages/react-native-executorch/scripts/download-libs.js
index aefdd1765e..82527b7d0c 100644
--- a/packages/react-native-executorch/scripts/download-libs.js
+++ b/packages/react-native-executorch/scripts/download-libs.js
@@ -172,32 +172,67 @@ const FEATURE_MAP = {
tokenizer: { backends: [], libs: [] },
};
-function readUserConfig() {
- const allOn = () => ({ backends: [...ALL_BACKENDS], libs: [...ALL_LIBS] });
+/**
+ * The `react-native-executorch` block, and the package.json it came from.
+ *
+ * Two places are tried, in order:
+ *
+ * 1. INIT_CWD - where the install was invoked. For a single-app project that
+ * is the app itself.
+ * 2. Each package.json above this package. In a workspace, `yarn install` is
+ * run at the root, so INIT_CWD points there and a block in
+ * `apps/mobile/package.json` would never be seen. When the app keeps its
+ * own node_modules (pnpm, nohoist), walking up from here lands on the app.
+ *
+ * A hoisted workspace resolves to the root either way, which is where the block
+ * has to live in that layout - there is no way to tell which of several apps a
+ * root install was meant for.
+ * @returns The block and the path of the package.json holding it, or both
+ * `undefined` when no manifest declares one.
+ */
+function findUserConfig() {
+ const candidates = [];
+ const initCwd = process.env.INIT_CWD || process.env.npm_config_local_prefix;
+ if (initCwd) candidates.push(initCwd);
+
+ // `__dirname` is /node_modules/react-native-executorch/scripts.
+ let dir = path.resolve(__dirname, '..', '..', '..');
+ for (let i = 0; i < 6; i++) {
+ if (!candidates.includes(dir)) candidates.push(dir);
+ const parent = path.dirname(dir);
+ if (parent === dir) break;
+ dir = parent;
+ }
- // npm/yarn set INIT_CWD to the directory where install was invoked (project root)
- const projectRoot = process.env.INIT_CWD || process.env.npm_config_local_prefix;
- if (!projectRoot) {
- console.warn(
- '[react-native-executorch] Could not determine project root, enabling all backends + libs.'
- );
- return allOn();
+ for (const root of candidates) {
+ const manifest = path.join(root, 'package.json');
+ let parsed;
+ try {
+ parsed = JSON.parse(fs.readFileSync(manifest, 'utf8'));
+ } catch {
+ continue;
+ }
+ if (parsed['react-native-executorch'] !== undefined) {
+ return { config: parsed['react-native-executorch'], manifest };
+ }
}
+ return { config: undefined, manifest: undefined };
+}
- let rneConfig;
- try {
- const userPackageJson = JSON.parse(
- fs.readFileSync(path.join(projectRoot, 'package.json'), 'utf8')
- );
- rneConfig = userPackageJson['react-native-executorch'];
- } catch {
- console.warn(
- '[react-native-executorch] Could not read app package.json, enabling all backends + libs.'
+function readUserConfig() {
+ const allOn = () => ({ backends: [...ALL_BACKENDS], libs: [...ALL_LIBS], opencvPod: undefined });
+
+ const { config: rneConfig, manifest } = findUserConfig();
+
+ if (rneConfig === undefined) {
+ console.log(
+ '[react-native-executorch] No `react-native-executorch` block found; enabling all backends + libs.'
);
return allOn();
}
-
- if (rneConfig === undefined) return allOn();
+ // Which manifest won matters when it is not the one the user edited - the
+ // usual monorepo surprise.
+ console.log(`[react-native-executorch] Read build config from ${manifest}`);
if (rneConfig.extras !== undefined) {
throw new Error(
@@ -236,10 +271,10 @@ function readUserConfig() {
}
}
- return { backends: [...backends], libs: [...libs] };
+ return { backends: [...backends], libs: [...libs], opencvPod: rneConfig.opencvPod };
}
-function writeBuildConfig({ backends, libs }) {
+function writeBuildConfig({ backends, libs, opencvPod }) {
const config = {
enableOpencv: libs.includes('opencv'),
enablePhonemis: libs.includes('phonemis'),
@@ -248,6 +283,10 @@ function writeBuildConfig({ backends, libs }) {
enableMlx: backends.includes('mlx'),
enableVulkan: backends.includes('vulkan'),
};
+ // Which pod provides opencv2 on iOS. Only set when the app asks for a
+ // specific one; otherwise the podspec picks, preferring an OpenCV the app
+ // already carries so two frameworks named opencv2 never meet.
+ if (opencvPod !== undefined) config.opencvPod = opencvPod;
fs.writeFileSync(
path.join(PACKAGE_ROOT, 'rne-build-config.json'),
JSON.stringify(config, null, 2)
@@ -497,4 +536,4 @@ if (require.main === module) {
});
}
-module.exports = { ALL_BACKENDS, ALL_LIBS, FEATURE_MAP };
+module.exports = { ALL_BACKENDS, ALL_LIBS, FEATURE_MAP, findUserConfig, readUserConfig };