Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 10 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,16 @@ pnpm add react-native-executorch react-native-worklets react-native-blob-util
```

> [!IMPORTANT]
> React Native ExecuTorch requires the **New React Native Architecture**, **React Native 0.81+** or **Expo SDK 54+** (using development builds), **iOS 17.0+**, and **Android 13+**.
> 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.14.0`)
> - **iOS 17.0+** / **Android 13+** (`minSdkVersion` >= 26)
> - **`react-native-worklets/plugin`** as the last entry in your Babel `plugins`
>
> Downloads of our pre-exported models send an anonymous download event; call `setTelemetryEnabled(false)` to opt out.
>
> See the [Getting Started Guide](https://docs.swmansion.com/react-native-executorch/docs/fundamentals/getting-started) and [Compatibility table](https://docs.swmansion.com/react-native-executorch/docs/other/compatibility) for more details.

### 2. Run the Model

Expand Down
2 changes: 1 addition & 1 deletion apps/computer-vision/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
"expo-router": "~56.2.9",
"react": "19.2.3",
"react-native": "0.85.3",
"react-native-blob-util": "^0.24.0",
"react-native-blob-util": "^0.25.0",
"react-native-drawer-layout": "^4.2.2",
"react-native-executorch": "workspace:*",
"react-native-gesture-handler": "~2.31.1",
Expand Down
2 changes: 1 addition & 1 deletion apps/nlp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@
"expo-router": "~56.2.9",
"react": "19.2.3",
"react-native": "0.85.3",
"react-native-blob-util": "^0.24.0",
"react-native-blob-util": "^0.25.0",
"react-native-drawer-layout": "^4.2.2",
"react-native-executorch": "workspace:*",
"react-native-gesture-handler": "~2.31.1",
Expand Down
2 changes: 1 addition & 1 deletion apps/speech/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@
"react": "19.2.3",
"react-native": "0.85.3",
"react-native-audio-api": "0.13.1",
"react-native-blob-util": "^0.24.0",
"react-native-blob-util": "^0.25.0",
"react-native-device-info": "^15.0.2",
"react-native-drawer-layout": "^4.2.2",
"react-native-executorch": "workspace:*",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,6 @@ android {

defaultConfig {
minSdkVersion 26
targetSdkVersion 34

externalNativeBuild {
cmake {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,13 @@ describe('react-native-worklets peer range', () => {
it('covers the releases published since', () => {
// A range that stops below the current release leaves anyone installing
// `react-native-worklets` with an unmet peer warning for no reason.
for (const version of ['0.10.4', '0.11.4', '0.12.2']) {
for (const version of ['0.10.4', '0.11.4', '0.12.2', '0.13.0']) {
expect(satisfies(version, range)).toBe(true);
}
});

it('is bounded, so an unreleased major cannot be assumed to work', () => {
expect(subset(range, '>=0.10.0 <0.13.0')).toBe(true);
expect(subset(range, '>=0.10.0 <0.14.0')).toBe(true);
});

it('is satisfied by the version installed in this repo', () => {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,12 +1,18 @@
/**
* The Android download path.
*
* `src/fetcher/fetcher.ts` decides between the two backends once, at module
* scope (`const IS_ANDROID = Platform.OS === 'android'`), so exercising the
* Android branch means re-importing the module with a different `Platform`.
* That also re-instantiates the blob-util mock, so every handle used here has
* to come from the same fresh module registry — hence the `load()` helper
* rather than the file-level imports the other fetcher suites use.
* Android used to have a backend of its own — the system `DownloadManager` —
* because blob-util's in-process reader stopped after 8 KB
* (RonRadtke/react-native-blob-util#475). That fix shipped in 0.24.11, so both
* platforms now share the streaming fallback, and what is left that is specific
* to Android is the cache directory.
*
* `src/fetcher/fetcher.ts` derives that directory once, at module scope
* (`const IS_ANDROID = Platform.OS === 'android'`), so exercising the Android
* branch means re-importing the module with a different `Platform`. That also
* re-instantiates the blob-util mock, so every handle used here has to come from
* the same fresh module registry — hence the `load()` helper rather than the
* file-level imports the other fetcher suites use.
*/
import type { Route } from '../support/blobUtilMock';

Expand All @@ -16,8 +22,12 @@ const HF_COUNTER = 'https://huggingface.co/software-mansion/model/resolve/main/c
type Android = {
download: typeof import('../../src/fetcher/fetcher').download;
serve: (url: string, route?: Route) => void;
requests: () => readonly { method: string; url: string; headers: Record<string, string> }[];
paths: () => string[];
readText: (path: string) => string | undefined;
write: (path: string, contents: string) => void;
remove: (path: string) => void;
has: (path: string) => boolean;
countRequests: (method: string, url: string) => number;
};

Expand Down Expand Up @@ -49,8 +59,12 @@ const load = async (): Promise<Android> => {
return {
download,
serve: blobUtil.fakeNet.serve,
requests: blobUtil.fakeNet.requests,
paths: blobUtil.fakeFs.paths,
readText: blobUtil.fakeFs.readText,
write: blobUtil.fakeFs.write,
remove: blobUtil.fakeFs.remove,
has: blobUtil.fakeFs.has,
countRequests: blobUtil.fakeNet.countRequests,
};
};
Expand All @@ -71,30 +85,36 @@ describe('download on Android', () => {
expect(android.readText(path)).toBe('model-bytes');
});

it('downloads through a temporary file and moves it into place', async () => {
it('resumes from a partial file with a Range request, like iOS', async () => {
const android = await load();
android.serve(URL_A);
android.serve(URL_A, { body: 'abcdefgh' });

await android.download(URL_A);

expect(android.paths().filter((p) => p.endsWith('.downloading'))).toEqual([]);
});
// Stage the aftermath of an interrupted download: the cached file is gone
// and `partial` bytes sit next to it. DownloadManager never resumed through
// a Range request, so this is what proves the shared backend is in use.
const path = await android.download(URL_A);
android.remove(path);
android.write(`${path}.partial`, 'abc');
const before = android.requests().length;

it('treats an empty response as a failure, since DownloadManager reports no status', async () => {
const android = await load();
android.serve(URL_A, { body: '' });
await android.download(URL_A);

await expect(android.download(URL_A)).rejects.toThrow(/empty response/);
expect(android.paths()).toEqual([]);
const ranged = android
.requests()
.slice(before)
.find((r) => r.headers.Range !== undefined);
expect(ranged?.headers.Range).toBe('bytes=3-');
expect(android.readText(path)).toBe('abcdefgh');
expect(android.has(`${path}.partial`)).toBe(false);
});

it('does not send a Range header — DownloadManager resumes on its own', async () => {
it('leaves no temporary files behind on success', async () => {
const android = await load();
android.serve(URL_A);

await android.download(URL_A);

expect(android.countRequests('GET', URL_A)).toBe(1);
expect(android.paths().filter((p) => /\.(partial|chunk|downloading)$/.test(p))).toEqual([]);
});

it('serves a second call from the cache', async () => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -356,7 +356,7 @@ describe('download — concurrent callers', () => {
});
});

describe('download — iOS resume', () => {
describe('download — resume', () => {
/**
* Stages the aftermath of an interrupted download: the cached file is gone
* and `partial` bytes are sitting next to it. The cache path is only known
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -210,7 +210,7 @@ type FetchTask = Promise<{ info: () => { status: number } }> & {
progress: (config: { count?: number }, cb: ProgressCallback) => FetchTask;
// Undocumented in blob-util's typings, but real: it reports the response
// state, so `src/` learns the status as soon as the headers land rather than
// only once the body is complete. See `downloadUrlViaIosStream`.
// only once the body is complete. See `downloadUrlViaStream`.
stateChange: (cb: StateChangeCallback) => FetchTask;
cancel: () => void;
};
Expand All @@ -223,14 +223,13 @@ class CancelledError extends Error {
}

type Config = {
/** Destination path for a streamed download (iOS-style). */
/** Destination path for a streamed download. */
path?: string;
fileCache?: boolean;
addAndroidDownloads?: { path?: string; useDownloadManager?: boolean; [key: string]: unknown };
};

function startFetch(config: Config, method: string, url: string, headers: Record<string, string>) {
const dest = config.addAndroidDownloads?.path ?? config.path;
const dest = config.path;
requests.push({ method, url, headers });

let onProgress: ProgressCallback | undefined;
Expand Down
7 changes: 4 additions & 3 deletions packages/react-native-executorch/android/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,12 @@ fun requireNativeArtifacts() {

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:
scripts by default ("Ignored build scripts"), npm 12 skips install
scripts not listed in `allowScripts`, and `--ignore-scripts` does the
same. Re-run the hook:

pnpm approve-builds react-native-executorch # pnpm
npm approve-scripts react-native-executorch # npm 12+, then:
npm rebuild react-native-executorch # npm
node node_modules/react-native-executorch/scripts/download-libs.js

Expand Down Expand Up @@ -114,7 +116,6 @@ android {
// 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")

externalNativeBuild {
Expand Down
3 changes: 3 additions & 0 deletions packages/react-native-executorch/babel.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@ module.exports = {
{
exclude: /\/node_modules\//,
presets: ['module:react-native-builder-bob/babel-preset'],
// `export * as ns` survives into lib/ otherwise, and the bare RN preset
// apps compile node_modules with cannot transform it.
plugins: ['@babel/plugin-transform-export-namespace-from'],
},
{
include: /\/node_modules\//,
Expand Down
32 changes: 15 additions & 17 deletions packages/react-native-executorch/ios/RnExecutorch.mm
Original file line number Diff line number Diff line change
@@ -1,35 +1,33 @@
#import "RnExecutorch.h"
#import "../cpp/RnExecutorch.h"
#import <React/RCTBridge+Private.h>
#import <ReactCommon/RCTTurboModuleWithJSIBindings.h>
#import <jsi/jsi.h>

@interface RnExecutorch () <RCTTurboModuleWithJSIBindings>
@end

@implementation RnExecutorch {
__weak RCTBridge *_bridge;
BOOL _installed;
}

@synthesize bridge = _bridge;

RCT_EXPORT_MODULE()

- (std::shared_ptr<facebook::react::TurboModule>)getTurboModule:
(const facebook::react::ObjCTurboModule::InitParams &)params {
return std::make_shared<facebook::react::NativeRnExecutorchSpecJSI>(params);
}

RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD(NSNumber *, install) {
RCTCxxBridge *cxxBridge = (RCTCxxBridge *)self.bridge;

if (cxxBridge == nil)
return @NO;

facebook::jsi::Runtime *jsiRuntime = (facebook::jsi::Runtime *)cxxBridge.runtime;

if (jsiRuntime == nil)
return @NO;

rnexecutorch::install(*jsiRuntime);
// The TurboModule manager calls this on the JS thread when the module is first
// required, so the bindings exist before JS calls `install()`. RCTCxxBridge,
// the previous route to the runtime, was removed in RN 0.87.
- (void)installJSIBindingsWithRuntime:(facebook::jsi::Runtime &)runtime
callInvoker:(const std::shared_ptr<facebook::react::CallInvoker> &)callInvoker {
rnexecutorch::install(runtime);
_installed = YES;
}

return @YES;
RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD(NSNumber *, install) {
return @(_installed);
}

@end
14 changes: 8 additions & 6 deletions packages/react-native-executorch/package.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"name": "react-native-executorch",
"version": "0.10.5",
"nativeLibsVersion": "0.10.4",
"nativeLibsVersion": "0.10.6",
"description": "An easy way to run AI models in React Native with ExecuTorch",
"main": "./lib/module/index.js",
"module": "./lib/module/index.js",
Expand Down Expand Up @@ -141,16 +141,17 @@
"@kesha-antonov/react-native-background-downloader": ">=4.4.0",
"react": "*",
"react-native": "*",
"react-native-blob-util": "^0.24.0",
"react-native-worklets": ">=0.10.0 <0.13.0"
"react-native-blob-util": ">=0.24.11",
"react-native-worklets": ">=0.10.0 <0.14.0"
},
"peerDependenciesMeta": {
"@kesha-antonov/react-native-background-downloader": {
"optional": true
}
},
"devDependencies": {
"@babel/core": "^7.25.1",
"@babel/core": "^7.29.7",
"@babel/plugin-transform-export-namespace-from": "^7.29.7",
"@react-native/babel-preset": "0.83.6",
"@react-native/metro-config": "^0.86.0",
"@testing-library/react-native": "^14.0.1",
Expand All @@ -160,7 +161,7 @@
"jest": "^29.7.0",
"react": "19.2.0",
"react-native": "0.83.6",
"react-native-blob-util": "^0.24.0",
"react-native-blob-util": "^0.25.0",
"react-native-builder-bob": "^0.40.18",
"react-native-worklets": "0.10.3",
"test-renderer": "^1.2.0",
Expand All @@ -173,7 +174,8 @@
[
"module",
{
"esm": true
"esm": true,
"configFile": true
}
],
[
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,10 +50,12 @@ unless missing.empty?

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:
default ("Ignored build scripts"), npm 12 skips install scripts not listed
in `allowScripts`, and `--ignore-scripts` does the same. Re-run the hook,
then `pod install` again:

pnpm approve-builds react-native-executorch # pnpm
npm approve-scripts react-native-executorch # npm 12+, then:
npm rebuild react-native-executorch # npm
node node_modules/react-native-executorch/scripts/download-libs.js

Expand Down
Loading