From d0bae85b6678ec4f9a21f0de6920de9a9e8c2c5b Mon Sep 17 00:00:00 2001 From: Mateusz Sluszniak <56299341+msluszniak@users.noreply.github.com> Date: Mon, 12 Oct 2026 20:08:57 +0100 Subject: [PATCH 1/8] fix(ios): support React Native 0.87 (#1525) ## Description - Install JSI bindings via `RCTTurboModuleWithJSIBindings`; RN 0.87 removed `RCTCxxBridge`. ### Introduces a breaking change? - [ ] Yes - [x] No ### Type of change - [x] Bug fix (change which fixes an issue) - [ ] New feature (change which adds functionality) - [ ] Documentation update (improves or adds clarity to existing documentation) - [ ] Other (chores, tests, code style improvements etc.) ### Tested on - [x] iOS - [ ] Android ### Testing instructions - Bare RN 0.87.1 app on the iOS simulator: `main` fails with `unknown type name 'RCTCxxBridge'`; this branch builds and runs a text embedding model. ### Screenshots ### Related issues ### Checklist - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have updated the documentation accordingly - [x] My changes generate no new warnings ### Additional notes (cherry picked from commit 0f940755804f7848cdb38b9b2fc95f7daf6b78a6) --- .../ios/RnExecutorch.mm | 32 +++++++++---------- 1 file changed, 15 insertions(+), 17 deletions(-) diff --git a/packages/react-native-executorch/ios/RnExecutorch.mm b/packages/react-native-executorch/ios/RnExecutorch.mm index d73302592a..9329f2368c 100644 --- a/packages/react-native-executorch/ios/RnExecutorch.mm +++ b/packages/react-native-executorch/ios/RnExecutorch.mm @@ -1,14 +1,15 @@ #import "RnExecutorch.h" #import "../cpp/RnExecutorch.h" -#import +#import #import +@interface RnExecutorch () +@end + @implementation RnExecutorch { - __weak RCTBridge *_bridge; + BOOL _installed; } -@synthesize bridge = _bridge; - RCT_EXPORT_MODULE() - (std::shared_ptr)getTurboModule: @@ -16,20 +17,17 @@ @implementation RnExecutorch { return std::make_shared(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 &)callInvoker { + rnexecutorch::install(runtime); + _installed = YES; +} - return @YES; +RCT_EXPORT_SYNCHRONOUS_TYPED_METHOD(NSNumber *, install) { + return @(_installed); } @end From 2b5c5ff2bd781c068518ed344811ee50031b18a6 Mon Sep 17 00:00:00 2001 From: Mateusz Sluszniak <56299341+msluszniak@users.noreply.github.com> Date: Wed, 14 Oct 2026 14:11:14 +0200 Subject: [PATCH 2/8] fix(deps): allow react-native-worklets 0.13 (#1535) Widens the `react-native-worklets` peer to `>=0.10.0 <0.14.0` so apps on Reanimated 4.7 (worklets 0.13) install cleanly. The APIs we use are unchanged in 0.13 and it still serializes `ArrayBufferView` natively. - [ ] Yes - [x] No - [x] Bug fix (change which fixes an issue) - [ ] New feature (change which adds functionality) - [ ] Documentation update (improves or adds clarity to existing documentation) - [ ] Other (chores, tests, code style improvements etc.) - [ ] iOS - [ ] Android - `yarn jest __tests__/api/workletsVersionRange.test.ts` - With worklets 0.13.0 swapped into `node_modules`: `tsc` clean, jest green except the docs floor check (it reads the RN floor from the installed worklets). Closes #1534 - [x] I have performed a self-review of my code - [ ] I have commented my code, particularly in hard-to-understand areas - [x] I have updated the documentation accordingly - [x] My changes generate no new warnings ~Not run on device: worklets 0.13 needs RN 0.86+, our apps are on 0.85.~ I've ran it on device and it worked. (cherry picked from commit 7f0c8b3a112be02eddc8fe87045709ad61a80668) --- docs/docs/01-fundamentals/01-getting-started.md | 2 +- docs/docs/05-other/01-compatibility.mdx | 2 +- .../__tests__/api/workletsVersionRange.test.ts | 4 ++-- packages/react-native-executorch/package.json | 2 +- yarn.lock | 2 +- 5 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/docs/01-fundamentals/01-getting-started.md b/docs/docs/01-fundamentals/01-getting-started.md index 461d81c04a..0daf9513a3 100644 --- a/docs/docs/01-fundamentals/01-getting-started.md +++ b/docs/docs/01-fundamentals/01-getting-started.md @@ -85,7 +85,7 @@ 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`) +- **`react-native-worklets` 0.10 or newer** (`>=0.10.0 <0.14.0`) - **iOS 17.0+** / **Android 13+** (`minSdkVersion` >= 26) For supported React Native versions, see the [Compatibility diff --git a/docs/docs/05-other/01-compatibility.mdx b/docs/docs/05-other/01-compatibility.mdx index e99fdbb98a..0f75b0e720 100644 --- a/docs/docs/05-other/01-compatibility.mdx +++ b/docs/docs/05-other/01-compatibility.mdx @@ -75,7 +75,7 @@ React Native ExecuTorch supports only the [New Architecture](https://reactnative
**\*** `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 +`>=0.10.0 <0.14.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` diff --git a/packages/react-native-executorch/__tests__/api/workletsVersionRange.test.ts b/packages/react-native-executorch/__tests__/api/workletsVersionRange.test.ts index 56187da90d..cba515f570 100644 --- a/packages/react-native-executorch/__tests__/api/workletsVersionRange.test.ts +++ b/packages/react-native-executorch/__tests__/api/workletsVersionRange.test.ts @@ -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', () => { diff --git a/packages/react-native-executorch/package.json b/packages/react-native-executorch/package.json index 1e45aba1de..719ba79964 100644 --- a/packages/react-native-executorch/package.json +++ b/packages/react-native-executorch/package.json @@ -142,7 +142,7 @@ "react": "*", "react-native": "*", "react-native-blob-util": "^0.24.0", - "react-native-worklets": ">=0.10.0 <0.13.0" + "react-native-worklets": ">=0.10.0 <0.14.0" }, "peerDependenciesMeta": { "@kesha-antonov/react-native-background-downloader": { diff --git a/yarn.lock b/yarn.lock index 9ce1d3e437..f42134c515 100644 --- a/yarn.lock +++ b/yarn.lock @@ -16506,7 +16506,7 @@ __metadata: react: "*" react-native: "*" react-native-blob-util: ^0.24.0 - react-native-worklets: ">=0.10.0 <0.13.0" + react-native-worklets: ">=0.10.0 <0.14.0" peerDependenciesMeta: "@kesha-antonov/react-native-background-downloader": optional: true From a0e8a036351085b0cbfdfefdc29ee3527af4a2bb Mon Sep 17 00:00:00 2001 From: Mateusz Sluszniak <56299341+msluszniak@users.noreply.github.com> Date: Tue, 13 Oct 2026 14:09:38 +0200 Subject: [PATCH 3/8] fix: bare RN setup gaps (#1526) - Compile `export * as` out of `lib/`, so bare RN apps no longer need `@babel/plugin-transform-export-namespace-from`. - Docs: worklets Babel plugin, Podfile iOS 17 target, telemetry opt-out, npm 12 install script approval, one-shot LLM generation. - [ ] Yes - [x] No - [x] Bug fix (change which fixes an issue) - [ ] New feature (change which adds functionality) - [x] Documentation update (improves or adds clarity to existing documentation) - [ ] Other (chores, tests, code style improvements etc.) - [ ] iOS - [ ] Android - `yarn prepare`, then check `lib/module/index.js` has no `export * as` and compiles with `@react-native/babel-preset`. - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have updated the documentation accordingly - [x] My changes generate no new warnings (cherry picked from commit b9c58fc2833650b7575b4722c5481483c965ddeb) --- README.md | 11 ++++- .../01-fundamentals/01-getting-started.md | 29 +++++++++++- .../02-llm-chat-and-generation.md | 46 +++++++++++++++++++ .../08-native-libraries.md | 9 ++-- docs/docs/05-other/02-troubleshooting.md | 8 ++-- .../android/build.gradle.kts | 6 ++- .../react-native-executorch/babel.config.js | 3 ++ packages/react-native-executorch/package.json | 6 ++- .../react-native-executorch.podspec | 6 ++- yarn.lock | 5 +- 10 files changed, 111 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index bffd7dc102..29b0a9d398 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/docs/docs/01-fundamentals/01-getting-started.md b/docs/docs/01-fundamentals/01-getting-started.md index 0daf9513a3..2c0abb2b24 100644 --- a/docs/docs/01-fundamentals/01-getting-started.md +++ b/docs/docs/01-fundamentals/01-getting-started.md @@ -86,7 +86,11 @@ 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) +- **iOS 17.0+** / **Android 13+** (`minSdkVersion` >= 26); a bare app's + `Podfile` needs `platform :ios, '17.0'` +- **`tar` on `PATH`** — the install-time download extracts with it; macOS, Linux + and Windows 10 1803+ all ship one. Windows and Linux hosts can build for + Android; iOS builds need macOS. For supported React Native versions, see the [Compatibility table](../05-other/01-compatibility.mdx). If an install or a build fails, see @@ -110,6 +114,29 @@ way. **Expo SDK 54 cannot be supported**: it is React Native 0.81, below what worklets 0.10 accepts. ::: +### Babel + +The library runs inference on worklet threads, so add the worklets Babel plugin +to `babel.config.js`. It must be the **last** entry in `plugins`: + +```js +module.exports = { + presets: ['module:@react-native/babel-preset'], // or 'babel-preset-expo' + plugins: [ + // ...your other plugins + 'react-native-worklets/plugin', + ], +}; +``` + +Restart Metro with `--reset-cache` after changing it. + +### Telemetry + +Downloading one of our pre-exported models sends an anonymous download event by +default. To opt out, call `setTelemetryEnabled(false)` once at startup. See +[Anonymous Telemetry](./02-downloading-models.md#anonymous-telemetry--download-counter). + ### Selecting native libraries The native binaries — the ExecuTorch hardware backends (XNNPACK, Core ML, MLX, diff --git a/docs/docs/02-extensions/natural-language/02-llm-chat-and-generation.md b/docs/docs/02-extensions/natural-language/02-llm-chat-and-generation.md index 4cf07802c1..3b4f209cf9 100644 --- a/docs/docs/02-extensions/natural-language/02-llm-chat-and-generation.md +++ b/docs/docs/02-extensions/natural-language/02-llm-chat-and-generation.md @@ -305,6 +305,52 @@ While [`useLLMChatSession`](../../06-api-reference/functions/useLLMChatSession.m - **KV Cache Rewind & Branching**: Call [`runner.reset(targetPos)`](../../06-api-reference/react-native-executorch/namespaces/llm/type-aliases/LLMRunner.md#reset) to rewind the KV cache back to an exact token position. This enables speculative branching, sampling multiple divergent continuations from a shared prompt prefix without re-encoding, or manual conversation tree management. - **Cancellation**: Call [`runner.stop()`](../../06-api-reference/react-native-executorch/namespaces/llm/type-aliases/LLMRunner.md#stop) from any thread to abort active autoregressive generation immediately. +### One-Shot Generation + +A chat session keeps every turn in its history and KV cache, so it is the wrong +tool for independent requests such as "rewrite this sentence" run many times. +For those, load the model once and drive the runner directly, resetting the KV +cache before each call: + +```typescript +import RNBlobUtil from 'react-native-blob-util'; +import { download, llm, models, wrapAsync } from 'react-native-executorch'; + +const model = await download(models.llm.LFM2_5_1_2B.DEFAULT); +const { chatTemplate, stopTokens } = llm.parseTokenizerConfig( + JSON.parse(await RNBlobUtil.fs.readFile(model.tokenizerConfigPath, 'utf8')) +); +const preprocessor = llm.createChatPreprocessor({ chatTemplate }); +const runner = await wrapAsync(llm.createLLMRunner)(model.modelPath, model.tokenizerPath); + +// Runs on a background worklet thread; `generate` is synchronous. +const complete = wrapAsync((r: llm.LLMRunner, prompt: llm.Prompt) => { + 'worklet'; + r.reset(); // start from an empty KV cache: no earlier call leaks in + let text = ''; + r.generate(prompt, { maxNewTokens: 256 }, (token) => { + if (!stopTokens.includes(token)) text += token; + }); + return text; +}); + +export async function generateOnce(system: string, user: string): Promise { + const messages: llm.ChatMessage[] = [ + { role: 'system', content: system }, + { role: 'user', content: user }, + ]; + const prompt = preprocessor.process(messages, messages.length); + try { + return await complete(runner, prompt); + } finally { + preprocessor.clear(); + } +} +``` + +Call `runner.dispose()` and `preprocessor.dispose()` when you no longer need the +model. Calls on one runner must not overlap; await each before starting the next. + ## Available Models The library provides ready-to-use models from the [Software Mansion HuggingFace LLM Collection](https://huggingface.co/collections/software-mansion/llm-multimodal), pre-packaged with their tokenizers and Jinja chat templates in [`models.llm`](../../06-api-reference/variables/models.md#llm): 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 22bee7ea79..e02c798d1d 100644 --- a/docs/docs/03-core-and-advanced/08-native-libraries.md +++ b/docs/docs/03-core-and-advanced/08-native-libraries.md @@ -56,10 +56,11 @@ The block is read from the directory the install was run in, then from every 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 +:::caution pnpm and npm 12 +pnpm 10 and later, and npm 12 and later, do not run dependency install 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` or +`npm approve-scripts react-native-executorch` once. See [Troubleshooting](../05-other/02-troubleshooting.md). ::: diff --git a/docs/docs/05-other/02-troubleshooting.md b/docs/docs/05-other/02-troubleshooting.md index f5fc608a3b..c6949f9606 100644 --- a/docs/docs/05-other/02-troubleshooting.md +++ b/docs/docs/05-other/02-troubleshooting.md @@ -22,6 +22,8 @@ The native artifacts are not in the npm tarball. They are downloaded by a - **pnpm 10 and later block dependency build scripts by default** and print `Ignored build scripts: react-native-executorch` during install. +- **npm 12 and later** (and npm 11.16+ with `strict-allow-scripts=true`) skip + install scripts of packages not listed in `allowScripts`, with only a warning. - `--ignore-scripts`, and `npm ci --ignore-scripts`, do the same. Nothing fails at that point — not even `pod install`, which does not check that @@ -31,6 +33,7 @@ configure phase) with this fix in the message instead. Re-run the hook: ```bash 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 ``` @@ -137,10 +140,7 @@ later. The library needs iOS 17. In an Expo app, set it explicitly — the defau is lower: ```json -[ - "expo-build-properties", - { "ios": { "deploymentTarget": "17.0" } } -] +["expo-build-properties", { "ios": { "deploymentTarget": "17.0" } }] ``` ## The `react-native-executorch` config block is ignored diff --git a/packages/react-native-executorch/android/build.gradle.kts b/packages/react-native-executorch/android/build.gradle.kts index f5af7ee6a4..9937aa1fbf 100644 --- a/packages/react-native-executorch/android/build.gradle.kts +++ b/packages/react-native-executorch/android/build.gradle.kts @@ -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 diff --git a/packages/react-native-executorch/babel.config.js b/packages/react-native-executorch/babel.config.js index 0c05fd6963..e6d58570c2 100644 --- a/packages/react-native-executorch/babel.config.js +++ b/packages/react-native-executorch/babel.config.js @@ -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\//, diff --git a/packages/react-native-executorch/package.json b/packages/react-native-executorch/package.json index 719ba79964..c64e0b6b89 100644 --- a/packages/react-native-executorch/package.json +++ b/packages/react-native-executorch/package.json @@ -150,7 +150,8 @@ } }, "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", @@ -173,7 +174,8 @@ [ "module", { - "esm": true + "esm": true, + "configFile": true } ], [ diff --git a/packages/react-native-executorch/react-native-executorch.podspec b/packages/react-native-executorch/react-native-executorch.podspec index 9a84e7e471..41a6baad84 100644 --- a/packages/react-native-executorch/react-native-executorch.podspec +++ b/packages/react-native-executorch/react-native-executorch.podspec @@ -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 diff --git a/yarn.lock b/yarn.lock index f42134c515..78ef785b87 100644 --- a/yarn.lock +++ b/yarn.lock @@ -64,7 +64,7 @@ __metadata: languageName: node linkType: hard -"@babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.20.0, @babel/core@npm:^7.25.1, @babel/core@npm:^7.25.2": +"@babel/core@npm:^7.11.6, @babel/core@npm:^7.12.3, @babel/core@npm:^7.20.0, @babel/core@npm:^7.25.2": version: 7.29.0 resolution: "@babel/core@npm:7.29.0" dependencies: @@ -16481,7 +16481,8 @@ __metadata: version: 0.0.0-use.local resolution: "react-native-executorch@workspace:packages/react-native-executorch" dependencies: - "@babel/core": "npm:^7.25.1" + "@babel/core": "npm:^7.29.7" + "@babel/plugin-transform-export-namespace-from": "npm:^7.29.7" "@huggingface/jinja": "npm:^0.5.9" "@react-native/babel-preset": "npm:0.83.6" "@react-native/metro-config": "npm:^0.86.0" From d3e1b7c7f74353fba7fe6f0431ccc4fdd86f90ce Mon Sep 17 00:00:00 2001 From: Mateusz Sluszniak <56299341+msluszniak@users.noreply.github.com> Date: Wed, 14 Oct 2026 16:02:46 +0200 Subject: [PATCH 4/8] fix: normalize CLIP image inputs with the CLIP mean and std (#1536) ## Description - CLIP image embeddings were fed `/255` pixels, but none of the exported image towers (XNNPACK, Core ML, MLX, Vulkan) normalize in-graph. `CLIP_IMAGE_EMBEDDINGS_OPTS` now applies the CLIP mean and std. ### Introduces a breaking change? - [ ] Yes - [x] No ### Type of change - [x] Bug fix (change which fixes an issue) - [ ] New feature (change which adds functionality) - [ ] Documentation update (improves or adds clarity to existing documentation) - [ ] Other (chores, tests, code style improvements etc.) ### Tested on - [ ] iOS - [ ] Android ### Testing instructions - `yarn workspace react-native-executorch test` ### Screenshots ### Related issues ### Checklist - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [ ] I have updated the documentation accordingly - [x] My changes generate no new warnings ### Additional notes ImageNetV2 zero-shot top-1 (1000 images, XNNPACK fp32, host): 44.8 before, 51.3 after. Embeddings stored before this change are not comparable with new ones. (cherry picked from commit a4a784ee0fda15ce08d2defe8338ef71508b22a9) --- packages/react-native-executorch/src/models.ts | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/react-native-executorch/src/models.ts b/packages/react-native-executorch/src/models.ts index 834c2e131e..b1679a9175 100644 --- a/packages/react-native-executorch/src/models.ts +++ b/packages/react-native-executorch/src/models.ts @@ -922,10 +922,15 @@ const LFM2_5_EMBEDDING_350M_MLX_INT4: TextEmbedderModel = { // ============================================================================= // Image Embeddings // ============================================================================= +// The exported image towers expect CLIP-normalized pixels (mean +// [0.48145466, 0.4578275, 0.40821073], std [0.26862954, 0.26130258, 0.27577711]). const CLIP_IMAGE_EMBEDDINGS_OPTS = { resizeMode: 'stretch' as const, interpolation: 'linear' as const, - normalizeOpts: { alpha: 1 / 255.0, beta: 0.0 }, + normalizeOpts: { + alpha: [1 / (255.0 * 0.26862954), 1 / (255.0 * 0.26130258), 1 / (255.0 * 0.27577711)], + beta: [-0.48145466 / 0.26862954, -0.4578275 / 0.26130258, -0.40821073 / 0.27577711], + }, }; const CLIP_VIT_BASE_PATCH32_IMAGE_XNNPACK_FP32: ImageEmbedderModel = { modelPath: `${BASE_URL}-clip-vit-base-patch32/${NEXT_VERSION_TAG}/xnnpack/clip_vit_base_patch32_image_xnnpack_fp32.pte`, From 4ba375b0ae30581ce25868d31a25a245fdc9142c Mon Sep 17 00:00:00 2001 From: Mateusz Sluszniak <56299341+msluszniak@users.noreply.github.com> Date: Thu, 24 Sep 2026 12:16:47 +0200 Subject: [PATCH 5/8] refactor(fetcher): drop the Android DownloadManager fallback (#1487) `react-native-blob-util` 0.24.11 fixed the Android in-process reader that stopped after 8 KB ([#475](https://github.com/RonRadtke/react-native-blob-util/issues/475)), which was the only reason Android had a download backend of its own. Both platforms now share the streaming fallback, leaving one mechanism (the optional background downloader) plus one shared fallback. - peer range `^0.24.0` -> `>=0.24.11`; apps and the lib devDependency move to `^0.25.0` - removes `downloadUrlViaAndroidDownloadManager` and the `IS_ANDROID` backend branch - removes `reassemble32BitCounter`; the 32-bit progress overflow came from DownloadManager's cursor column being read with `getInt`, and the in-process path reports bytes as real numbers - the Android cache directory stays where it is, since moving it would orphan every model already downloaded - [ ] Yes - [x] No - [ ] Bug fix (change which fixes an issue) - [ ] New feature (change which adds functionality) - [ ] Documentation update (improves or adds clarity to existing documentation) - [x] Other (chores, tests, code style improvements etc.) - [ ] iOS - [x] Android 1. Run `apps/nlp` on an Android device without `@kesha-antonov/react-native-background-downloader` installed. 2. Download a model larger than 2 GB, e.g. `llama_3_2_3b_xnnpack_spinquant.pte`. 3. Progress should climb monotonically to 100% and the cached file should match the server's length. Closes #1401 - [x] I have performed a self-review of my code - [x] I have commented my code, particularly in hard-to-understand areas - [x] I have updated the documentation accordingly - [x] My changes generate no new warnings Verified on a Galaxy S26 Ultra: 2,553,367,552 bytes downloaded in 146 s, matching the server's `x-linked-size` exactly. This is the first time the >2 GB path has run end to end, since it has been broken since it landed. Without the optional background downloader, Android downloads no longer continue while the app is suspended; they resume on the next `download` call, as on iOS. (cherry picked from commit c3c05bca46d2acb4a12b6e2ade24848543b730f3) --- apps/computer-vision/package.json | 2 +- apps/nlp/package.json | 2 +- apps/speech/package.json | 2 +- .../01-fundamentals/02-downloading-models.md | 10 +- .../__tests__/fetcher/androidBackend.test.ts | 58 ++++-- .../__tests__/fetcher/download.test.ts | 2 +- .../__tests__/support/blobUtilMock.ts | 7 +- packages/react-native-executorch/package.json | 4 +- .../src/fetcher/fetcher.ts | 169 ++++-------------- yarn.lock | 41 +++-- 10 files changed, 109 insertions(+), 188 deletions(-) diff --git a/apps/computer-vision/package.json b/apps/computer-vision/package.json index 9804b0fc47..732e124e31 100644 --- a/apps/computer-vision/package.json +++ b/apps/computer-vision/package.json @@ -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", diff --git a/apps/nlp/package.json b/apps/nlp/package.json index 1c3add20bb..6684f997ed 100644 --- a/apps/nlp/package.json +++ b/apps/nlp/package.json @@ -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", diff --git a/apps/speech/package.json b/apps/speech/package.json index 7cfe26d848..3287d2852f 100644 --- a/apps/speech/package.json +++ b/apps/speech/package.json @@ -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:*", diff --git a/docs/docs/01-fundamentals/02-downloading-models.md b/docs/docs/01-fundamentals/02-downloading-models.md index 66b2ada3ca..87e4c8b51a 100644 --- a/docs/docs/01-fundamentals/02-downloading-models.md +++ b/docs/docs/01-fundamentals/02-downloading-models.md @@ -98,9 +98,7 @@ common needs: isn't reported the same as a small tokenizer. - [**`signal`**](../06-api-reference/interfaces/DownloadOptions.md#signal) — an `AbortSignal` to cancel. The bytes fetched so far are kept so a later download - of the same source resumes instead of restarting (except on Android without - the optional background downloader, where the system `DownloadManager` discards - a cancelled transfer). + of the same source resumes instead of restarting. - [**`forceDownload`**](../06-api-reference/interfaces/DownloadOptions.md#forcedownload) — re-download even when cached. @@ -111,10 +109,8 @@ launches fast: - A file that is already cached resolves immediately — no network round trip. - Concurrent downloads of the same URL are deduplicated into one transfer. -- Without extra dependencies, fetching falls back to what each platform supports - natively: the system `DownloadManager` on Android (which continues in the - background), and a streaming request on iOS (which pauses when the app is - suspended and resumes when reopened). +- Without extra dependencies, both platforms fall back to the same streaming + request, which pauses when the app is suspended and resumes when reopened. - To keep transfers running in the background across both iOS and Android and survive the app being killed, install the optional peer dependency [`@kesha-antonov/react-native-background-downloader`](https://github.com/kesha-antonov/react-native-background-downloader) diff --git a/packages/react-native-executorch/__tests__/fetcher/androidBackend.test.ts b/packages/react-native-executorch/__tests__/fetcher/androidBackend.test.ts index 2b8bdd95ee..e4cf3654ce 100644 --- a/packages/react-native-executorch/__tests__/fetcher/androidBackend.test.ts +++ b/packages/react-native-executorch/__tests__/fetcher/androidBackend.test.ts @@ -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'; @@ -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 }[]; 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; }; @@ -49,8 +59,12 @@ const load = async (): Promise => { 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, }; }; @@ -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 () => { diff --git a/packages/react-native-executorch/__tests__/fetcher/download.test.ts b/packages/react-native-executorch/__tests__/fetcher/download.test.ts index 53f480a8b8..003ac5180a 100644 --- a/packages/react-native-executorch/__tests__/fetcher/download.test.ts +++ b/packages/react-native-executorch/__tests__/fetcher/download.test.ts @@ -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 diff --git a/packages/react-native-executorch/__tests__/support/blobUtilMock.ts b/packages/react-native-executorch/__tests__/support/blobUtilMock.ts index e4f0efecd5..974bb99eae 100644 --- a/packages/react-native-executorch/__tests__/support/blobUtilMock.ts +++ b/packages/react-native-executorch/__tests__/support/blobUtilMock.ts @@ -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; }; @@ -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) { - const dest = config.addAndroidDownloads?.path ?? config.path; + const dest = config.path; requests.push({ method, url, headers }); let onProgress: ProgressCallback | undefined; diff --git a/packages/react-native-executorch/package.json b/packages/react-native-executorch/package.json index c64e0b6b89..884b14e097 100644 --- a/packages/react-native-executorch/package.json +++ b/packages/react-native-executorch/package.json @@ -141,7 +141,7 @@ "@kesha-antonov/react-native-background-downloader": ">=4.4.0", "react": "*", "react-native": "*", - "react-native-blob-util": "^0.24.0", + "react-native-blob-util": ">=0.24.11", "react-native-worklets": ">=0.10.0 <0.14.0" }, "peerDependenciesMeta": { @@ -161,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", diff --git a/packages/react-native-executorch/src/fetcher/fetcher.ts b/packages/react-native-executorch/src/fetcher/fetcher.ts index 1ae112716d..dc0985c0a6 100644 --- a/packages/react-native-executorch/src/fetcher/fetcher.ts +++ b/packages/react-native-executorch/src/fetcher/fetcher.ts @@ -14,9 +14,11 @@ const IS_ANDROID = Platform.OS === 'android'; // Persistent, per-app directory where downloaded model assets are cached. // iOS: internal DocumentDir (not CacheDir) so the OS won't evict large models // between runs and force a costly re-download. -// Android: the app-private EXTERNAL files dir (getExternalFilesDir), so the -// system DownloadManager can write there and same-volume moves stay cheap -// even for multi-GB files. Falls back to DocumentDir if unmounted. +// Android: the app-private EXTERNAL files dir (getExternalFilesDir). It was +// picked so the system DownloadManager could stage into it; that backend +// is gone, but every model already on disk lives here, so moving the +// cache now would silently orphan all of them and re-download multi-GB +// files. Falls back to DocumentDir if unmounted. const ANDROID_DIRECTORY = RNBlobUtil.fs.dirs.SDCardDir || RNBlobUtil.fs.dirs.DocumentDir; const RNE_DIRECTORY = IS_ANDROID ? `${ANDROID_DIRECTORY}/react-native-executorch` @@ -31,9 +33,7 @@ export interface DownloadOptions { onProgress?: (progress: number) => void; /** * Aborts the download. The bytes fetched so far are kept so a later - * {@link download} of the same source resumes instead of restarting, except on - * Android without the optional background downloader, where the system - * DownloadManager discards a cancelled transfer. + * {@link download} of the same source resumes instead of restarting. */ signal?: AbortSignal; /** @@ -176,24 +176,6 @@ async function foldResumedChunkIntoPartial( await RNBlobUtil.fs.unlink(chunkPath).catch(() => {}); } -// DownloadManager's byte counter is 64-bit, but blob-util reads it out of the -// cursor with `getInt`, so what reaches JS is the low 32 bits reinterpreted as -// a signed int: past 2 GB it arrives NEGATIVE and wraps every 4 GB after that. -// Multi-GB LLM models spend most of their download inside that range, which is -// what collapsed their progress bar. The counter only ever grows, so the -// discarded high bits can be rebuilt by counting how often the low ones wrap. -const UINT32 = 0x100000000; -function reassemble32BitCounter(): (raw: number) => number { - let wraps = 0; - let previous = 0; - return (raw) => { - const low = raw < 0 ? raw + UINT32 : raw; - if (low < previous) wraps += 1; - previous = low; - return low + wraps * UINT32; - }; -} - // Reports absolute bytes for one file. `total` is 0 when the transfer does not // know the length yet — the receiver keeps using whatever length it already // had rather than treating the file as complete. @@ -249,8 +231,7 @@ async function downloadUrl(url: string, cb: DownloadUrlCallbacks): Promise 0) cb.onBytes?.(finalSize, finalSize); @@ -347,72 +317,6 @@ function joinDownload(entry: InFlightDownload, cb: DownloadUrlCallbacks): Promis }); } -// Android fallback used when that optional dependency is absent: the system -// DownloadManager streams to app-private external storage. blob-util's -// in-process reader cannot stand in for it — upstream #475 makes that path stop -// after 8 KB — and DownloadManager also handles files larger than 2 GB, keeps -// downloading while the app is in the background or killed, and resumes across -// transient network drops on its own, so no manual Range logic. -async function downloadUrlViaAndroidDownloadManager( - url: string, - dest: string, - cb: DownloadUrlCallbacks -): Promise { - const tmp = `${dest}.downloading`; - await RNBlobUtil.fs.unlink(tmp).catch(() => {}); - - if (cb.signal?.aborted) throw abortError(); - - const task = RNBlobUtil.config({ - addAndroidDownloads: { - useDownloadManager: true, - path: tmp, - notification: false, - mediaScannable: false, - mime: 'application/octet-stream', - }, - }).fetch('GET', url); - - const onAbort = () => task.cancel(); - cb.signal?.addEventListener('abort', onAbort); - - // DownloadManager reports total as -1 until the size is known; pass that on - // as 0 rather than echoing the received count, which would otherwise look - // like a file that is complete at every sample. - const absoluteBytes = reassemble32BitCounter(); - task.progress({ count: 100 }, (received, total) => { - const tot = Number(total); - cb.onBytes?.(absoluteBytes(Number(received)), tot > 0 ? tot : 0); - }); - - try { - await task; - } catch (e) { - await RNBlobUtil.fs.unlink(tmp).catch(() => {}); - throw cb.signal?.aborted ? abortError() : e; - } finally { - cb.signal?.removeEventListener('abort', onAbort); - } - - // DownloadManager doesn't surface an HTTP status; an empty file means failure. - const size = await fileSize(tmp); - if (size <= 0) { - await RNBlobUtil.fs.unlink(tmp).catch(() => {}); - throw RnExecuTorchError('DOWNLOAD_FAILED', `Download of ${url} failed (empty response).`); - } - // A non-empty file is not necessarily a complete one, and DownloadManager - // gives us no status to check. Promoting a short file would cache it under - // its final name forever — the existence-only cache check can't tell the - // difference, and a truncated .pte only fails much later, at load. - const expected = await expectedBytesFor(url, cb.expectedBytes, cb.signal); - if (isTruncated(size, expected)) { - await RNBlobUtil.fs.unlink(tmp).catch(() => {}); - throw incompleteError(url, size, expected); - } - await RNBlobUtil.fs.mv(tmp, dest); - return dest; -} - // One background task per destination file, under an id that stays the same // across app launches: that is what lets a later call adopt a transfer this // process never started. @@ -579,14 +483,19 @@ async function downloadUrlViaBackgroundSession( return dest; } -// iOS fallback used when that optional dependency is absent: blob-util streams -// via the iOS URL session straight to disk. It does NOT survive the app being -// suspended — iOS tears the connection down about a second later — so an -// interrupted transfer is picked up by the next `download` call instead. -// Interrupted downloads resume from a `.partial` file via an HTTP Range request. -// `canResume` is set to `false` on an internal retry to avoid recursing forever -// if partial-file assembly ever fails. -async function downloadUrlViaIosStream( +// The fallback used on either platform when that optional dependency is absent: +// blob-util streams the response straight to disk, in process. It does NOT +// survive the app being suspended — iOS tears the connection down about a +// second later, and Android may kill the process outright — so an interrupted +// transfer is picked up by the next `download` call instead: it resumes from a +// `.partial` file via an HTTP Range request. `canResume` is set to `false` on an +// internal retry to avoid recursing forever if partial-file assembly ever fails. +// +// This path needs react-native-blob-util >=0.24.11 on Android. 0.24.10's reader +// stopped after exactly 8 KB with "Download interrupted" +// (RonRadtke/react-native-blob-util#475), which is why Android used the system +// DownloadManager until that fix shipped. +async function downloadUrlViaStream( url: string, dest: string, cb: DownloadUrlCallbacks, @@ -638,9 +547,9 @@ async function downloadUrlViaIosStream( }); } - // Same granularity as Android. blob-util still floors the rate at one event - // per 250 ms, so this only means a large file advances in ~1% steps instead - // of the 5% ones that made a multi-GB download look stalled between jumps. + // blob-util floors the rate at one event per 250 ms regardless, so this only + // means a large file advances in ~1% steps instead of the 5% ones that made a + // multi-GB download look stalled between jumps. task.progress({ count: 100 }, (received, total) => { const recv = Number(received); const tot = Number(total); @@ -733,14 +642,14 @@ async function downloadUrlViaIosStream( // once as a plain full download so correctness never depends on resume. await RNBlobUtil.fs.unlink(part).catch(() => {}); await RNBlobUtil.fs.unlink(target).catch(() => {}); - if (canResume) return downloadUrlViaIosStream(url, dest, cb, false); + if (canResume) return downloadUrlViaStream(url, dest, cb, false); throw assemblyErr; } if (restart) { await RNBlobUtil.fs.unlink(part).catch(() => {}); await RNBlobUtil.fs.unlink(target).catch(() => {}); - return downloadUrlViaIosStream(url, dest, cb, false); + return downloadUrlViaStream(url, dest, cb, false); } // The transfer can also report success while the body was cut short — a @@ -756,7 +665,7 @@ async function downloadUrlViaIosStream( // start over rather than fail. A fresh transfer that overshoots is a wrong // expectation instead, and falls through to be accepted. await RNBlobUtil.fs.unlink(part).catch(() => {}); - return downloadUrlViaIosStream(url, dest, cb, false); + return downloadUrlViaStream(url, dest, cb, false); } if (isTruncated(assembled, expected)) { // Short: keep the partial so the next call resumes and finishes it. @@ -859,10 +768,8 @@ function substituteRemoteSources(node: T, resolved: ReadonlyMap=4.4.0" react: "*" react-native: "*" - react-native-blob-util: ^0.24.0 + react-native-blob-util: ">=0.24.11" react-native-worklets: ">=0.10.0 <0.14.0" peerDependenciesMeta: "@kesha-antonov/react-native-background-downloader": @@ -17920,7 +17919,7 @@ __metadata: react: "npm:19.2.3" react-native: "npm:0.85.3" react-native-audio-api: "npm:0.13.1" - react-native-blob-util: "npm:^0.24.0" + react-native-blob-util: "npm:^0.25.0" react-native-device-info: "npm:^15.0.2" react-native-drawer-layout: "npm:^4.2.2" react-native-executorch: "workspace:*" From e9aa0d461c7cf4639b2c44aada880d22edbd97e8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Mon, 19 Oct 2026 14:42:21 +0200 Subject: [PATCH 6/8] fix(android): drop library targetSdk for AGP 9 AGP 9 (RN 0.88) removed targetSdk from library defaultConfig, so the build script fails to compile. It never affected the app. (cherry picked from commit 887c4ecaeb1ce7d55c46d6bdea7e70fd5dc81855) --- packages/react-native-executorch-webrtc/android/build.gradle | 1 - packages/react-native-executorch/android/build.gradle.kts | 1 - 2 files changed, 2 deletions(-) diff --git a/packages/react-native-executorch-webrtc/android/build.gradle b/packages/react-native-executorch-webrtc/android/build.gradle index da727c1f6d..6e0b2be573 100644 --- a/packages/react-native-executorch-webrtc/android/build.gradle +++ b/packages/react-native-executorch-webrtc/android/build.gradle @@ -31,7 +31,6 @@ android { defaultConfig { minSdkVersion 26 - targetSdkVersion 34 externalNativeBuild { cmake { diff --git a/packages/react-native-executorch/android/build.gradle.kts b/packages/react-native-executorch/android/build.gradle.kts index 9937aa1fbf..8dfea64165 100644 --- a/packages/react-native-executorch/android/build.gradle.kts +++ b/packages/react-native-executorch/android/build.gradle.kts @@ -116,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 { From 84c28c30cd87819a5a9e1a40a36aeaf72265661d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Thu, 8 Oct 2026 13:03:09 +0200 Subject: [PATCH 7/8] fix(libs): bump native libs to v0.10.6-libs Picks up the Vulkan Android backend with pytorch/executorch#23381 (Adreno 720/740 pipeline-creation crash in mean/var shaders) and the restored bias add in the 8da4w Vulkan linear shaders. (cherry picked from commit 1ee078677b249391d9d7b9dd75ffac637c3de994) --- packages/react-native-executorch/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-executorch/package.json b/packages/react-native-executorch/package.json index 884b14e097..19746e9d9f 100644 --- a/packages/react-native-executorch/package.json +++ b/packages/react-native-executorch/package.json @@ -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", From 5c7b9d5ce5e88f3f5898b452314c2598464ffd65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Mon, 19 Oct 2026 15:45:26 +0200 Subject: [PATCH 8/8] docs: drop docs changes from the backport --- .../01-fundamentals/01-getting-started.md | 31 +------------ .../01-fundamentals/02-downloading-models.md | 10 ++-- .../02-llm-chat-and-generation.md | 46 ------------------- .../08-native-libraries.md | 9 ++-- docs/docs/05-other/01-compatibility.mdx | 2 +- docs/docs/05-other/02-troubleshooting.md | 8 ++-- 6 files changed, 18 insertions(+), 88 deletions(-) diff --git a/docs/docs/01-fundamentals/01-getting-started.md b/docs/docs/01-fundamentals/01-getting-started.md index 2c0abb2b24..461d81c04a 100644 --- a/docs/docs/01-fundamentals/01-getting-started.md +++ b/docs/docs/01-fundamentals/01-getting-started.md @@ -85,12 +85,8 @@ 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); a bare app's - `Podfile` needs `platform :ios, '17.0'` -- **`tar` on `PATH`** — the install-time download extracts with it; macOS, Linux - and Windows 10 1803+ all ship one. Windows and Linux hosts can build for - Android; iOS builds need macOS. +- **`react-native-worklets` 0.10 or newer** (`>=0.10.0 <0.13.0`) +- **iOS 17.0+** / **Android 13+** (`minSdkVersion` >= 26) For supported React Native versions, see the [Compatibility table](../05-other/01-compatibility.mdx). If an install or a build fails, see @@ -114,29 +110,6 @@ way. **Expo SDK 54 cannot be supported**: it is React Native 0.81, below what worklets 0.10 accepts. ::: -### Babel - -The library runs inference on worklet threads, so add the worklets Babel plugin -to `babel.config.js`. It must be the **last** entry in `plugins`: - -```js -module.exports = { - presets: ['module:@react-native/babel-preset'], // or 'babel-preset-expo' - plugins: [ - // ...your other plugins - 'react-native-worklets/plugin', - ], -}; -``` - -Restart Metro with `--reset-cache` after changing it. - -### Telemetry - -Downloading one of our pre-exported models sends an anonymous download event by -default. To opt out, call `setTelemetryEnabled(false)` once at startup. See -[Anonymous Telemetry](./02-downloading-models.md#anonymous-telemetry--download-counter). - ### Selecting native libraries The native binaries — the ExecuTorch hardware backends (XNNPACK, Core ML, MLX, diff --git a/docs/docs/01-fundamentals/02-downloading-models.md b/docs/docs/01-fundamentals/02-downloading-models.md index 87e4c8b51a..66b2ada3ca 100644 --- a/docs/docs/01-fundamentals/02-downloading-models.md +++ b/docs/docs/01-fundamentals/02-downloading-models.md @@ -98,7 +98,9 @@ common needs: isn't reported the same as a small tokenizer. - [**`signal`**](../06-api-reference/interfaces/DownloadOptions.md#signal) — an `AbortSignal` to cancel. The bytes fetched so far are kept so a later download - of the same source resumes instead of restarting. + of the same source resumes instead of restarting (except on Android without + the optional background downloader, where the system `DownloadManager` discards + a cancelled transfer). - [**`forceDownload`**](../06-api-reference/interfaces/DownloadOptions.md#forcedownload) — re-download even when cached. @@ -109,8 +111,10 @@ launches fast: - A file that is already cached resolves immediately — no network round trip. - Concurrent downloads of the same URL are deduplicated into one transfer. -- Without extra dependencies, both platforms fall back to the same streaming - request, which pauses when the app is suspended and resumes when reopened. +- Without extra dependencies, fetching falls back to what each platform supports + natively: the system `DownloadManager` on Android (which continues in the + background), and a streaming request on iOS (which pauses when the app is + suspended and resumes when reopened). - To keep transfers running in the background across both iOS and Android and survive the app being killed, install the optional peer dependency [`@kesha-antonov/react-native-background-downloader`](https://github.com/kesha-antonov/react-native-background-downloader) diff --git a/docs/docs/02-extensions/natural-language/02-llm-chat-and-generation.md b/docs/docs/02-extensions/natural-language/02-llm-chat-and-generation.md index 3b4f209cf9..4cf07802c1 100644 --- a/docs/docs/02-extensions/natural-language/02-llm-chat-and-generation.md +++ b/docs/docs/02-extensions/natural-language/02-llm-chat-and-generation.md @@ -305,52 +305,6 @@ While [`useLLMChatSession`](../../06-api-reference/functions/useLLMChatSession.m - **KV Cache Rewind & Branching**: Call [`runner.reset(targetPos)`](../../06-api-reference/react-native-executorch/namespaces/llm/type-aliases/LLMRunner.md#reset) to rewind the KV cache back to an exact token position. This enables speculative branching, sampling multiple divergent continuations from a shared prompt prefix without re-encoding, or manual conversation tree management. - **Cancellation**: Call [`runner.stop()`](../../06-api-reference/react-native-executorch/namespaces/llm/type-aliases/LLMRunner.md#stop) from any thread to abort active autoregressive generation immediately. -### One-Shot Generation - -A chat session keeps every turn in its history and KV cache, so it is the wrong -tool for independent requests such as "rewrite this sentence" run many times. -For those, load the model once and drive the runner directly, resetting the KV -cache before each call: - -```typescript -import RNBlobUtil from 'react-native-blob-util'; -import { download, llm, models, wrapAsync } from 'react-native-executorch'; - -const model = await download(models.llm.LFM2_5_1_2B.DEFAULT); -const { chatTemplate, stopTokens } = llm.parseTokenizerConfig( - JSON.parse(await RNBlobUtil.fs.readFile(model.tokenizerConfigPath, 'utf8')) -); -const preprocessor = llm.createChatPreprocessor({ chatTemplate }); -const runner = await wrapAsync(llm.createLLMRunner)(model.modelPath, model.tokenizerPath); - -// Runs on a background worklet thread; `generate` is synchronous. -const complete = wrapAsync((r: llm.LLMRunner, prompt: llm.Prompt) => { - 'worklet'; - r.reset(); // start from an empty KV cache: no earlier call leaks in - let text = ''; - r.generate(prompt, { maxNewTokens: 256 }, (token) => { - if (!stopTokens.includes(token)) text += token; - }); - return text; -}); - -export async function generateOnce(system: string, user: string): Promise { - const messages: llm.ChatMessage[] = [ - { role: 'system', content: system }, - { role: 'user', content: user }, - ]; - const prompt = preprocessor.process(messages, messages.length); - try { - return await complete(runner, prompt); - } finally { - preprocessor.clear(); - } -} -``` - -Call `runner.dispose()` and `preprocessor.dispose()` when you no longer need the -model. Calls on one runner must not overlap; await each before starting the next. - ## Available Models The library provides ready-to-use models from the [Software Mansion HuggingFace LLM Collection](https://huggingface.co/collections/software-mansion/llm-multimodal), pre-packaged with their tokenizers and Jinja chat templates in [`models.llm`](../../06-api-reference/variables/models.md#llm): 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 e02c798d1d..22bee7ea79 100644 --- a/docs/docs/03-core-and-advanced/08-native-libraries.md +++ b/docs/docs/03-core-and-advanced/08-native-libraries.md @@ -56,11 +56,10 @@ The block is read from the directory the install was run in, then from every actually resolved, and the install log names the manifest it read. ::: -:::caution pnpm and npm 12 -pnpm 10 and later, and npm 12 and later, do not run dependency install 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` or -`npm approve-scripts react-native-executorch` once. See +:::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). ::: diff --git a/docs/docs/05-other/01-compatibility.mdx b/docs/docs/05-other/01-compatibility.mdx index 0f75b0e720..e99fdbb98a 100644 --- a/docs/docs/05-other/01-compatibility.mdx +++ b/docs/docs/05-other/01-compatibility.mdx @@ -75,7 +75,7 @@ React Native ExecuTorch supports only the [New Architecture](https://reactnative
**\*** `react-native-executorch` 0.10 needs `react-native-worklets` -`>=0.10.0 <0.14.0`, and worklets 0.10 is the first release to serialize an +`>=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` diff --git a/docs/docs/05-other/02-troubleshooting.md b/docs/docs/05-other/02-troubleshooting.md index c6949f9606..f5fc608a3b 100644 --- a/docs/docs/05-other/02-troubleshooting.md +++ b/docs/docs/05-other/02-troubleshooting.md @@ -22,8 +22,6 @@ The native artifacts are not in the npm tarball. They are downloaded by a - **pnpm 10 and later block dependency build scripts by default** and print `Ignored build scripts: react-native-executorch` during install. -- **npm 12 and later** (and npm 11.16+ with `strict-allow-scripts=true`) skip - install scripts of packages not listed in `allowScripts`, with only a warning. - `--ignore-scripts`, and `npm ci --ignore-scripts`, do the same. Nothing fails at that point — not even `pod install`, which does not check that @@ -33,7 +31,6 @@ configure phase) with this fix in the message instead. Re-run the hook: ```bash 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 ``` @@ -140,7 +137,10 @@ later. The library needs iOS 17. In an Expo app, set it explicitly — the defau is lower: ```json -["expo-build-properties", { "ios": { "deploymentTarget": "17.0" } }] +[ + "expo-build-properties", + { "ios": { "deploymentTarget": "17.0" } } +] ``` ## The `react-native-executorch` config block is ignored