From 8cf33a448803ab31a94ce786693d180cde569d06 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Wed, 9 Sep 2026 12:09:11 +0200 Subject: [PATCH 1/4] feat(core): support react-native-worklets 0.8 and 0.9 The peer was `^0.10.0`, which pins the library to React Native 0.83-0.86: worklets 0.10 requires RN 0.83+, so RN 0.81/0.82 and Expo SDK 54-56 could not install it at all. Widen it to `>=0.8.0 <0.11.0` - 0.8.0 is where `runOnRuntimeAsync` landed, the only API here newer than 0.5 - and close the one behavioural gap that opens up. Below 0.10.0 worklets serializes an ArrayBufferView by rebuilding it as `new Ctor(buffer)` on the target runtime, dropping byteOffset and length, so a window into a larger buffer (a slice of a recording, a pooled camera frame) arrives silently widened. Measured on a simulator, `new Uint8Array(buffer, 64, 16)` comes back with length 1024 on worklets 0.8.3 and 16 on 0.10.4. wrapAsync now tightens both directions first: any view that does not span its whole buffer is copied into one that does. An already-tight view - every buffer coming back from native - is passed through by reference, so the walk allocates nothing on the hot path. Also correct the documented range. The table claimed RN 0.81+ for 0.10.x with no mention of 0.86. --- .../01-fundamentals/01-getting-started.md | 9 +- docs/docs/05-other/01-compatibility.mdx | 29 +++- .../CompatibilityTable/styles.module.css | 19 +++ .../__tests__/core/runtime.test.ts | 37 +++++ .../__tests__/core/serialization.test.ts | 131 ++++++++++++++++++ packages/react-native-executorch/package.json | 2 +- .../src/core/runtime.ts | 9 +- .../src/core/serialization.ts | 120 ++++++++++++++++ yarn.lock | 2 +- 9 files changed, 351 insertions(+), 7 deletions(-) create mode 100644 packages/react-native-executorch/__tests__/core/serialization.test.ts create mode 100644 packages/react-native-executorch/src/core/serialization.ts diff --git a/docs/docs/01-fundamentals/01-getting-started.md b/docs/docs/01-fundamentals/01-getting-started.md index bfc9a94e42..74f6bfb8ca 100644 --- a/docs/docs/01-fundamentals/01-getting-started.md +++ b/docs/docs/01-fundamentals/01-getting-started.md @@ -84,9 +84,16 @@ 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) +- **React Native 0.81+** with [`react-native-worklets`](https://github.com/software-mansion/react-native-worklets) in the `>=0.8.0 <0.11.0` range, 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.81 and 0.82 pin you to worklets 0.8/0.9, which serialize an +`ArrayBufferView` slightly differently — the library works around it, see the +[Compatibility table](../05-other/01-compatibility.mdx). Expo SDK 54 and 55 +bundle worklets 0.5 and 0.7, below the supported range, so on those SDKs you +have to move to 0.8+ yourself; the version Reanimated expects is pinned per SDK, +so check both before upgrading. + For supported React Native versions, see the [Compatibility table](../05-other/01-compatibility.mdx). ::: diff --git a/docs/docs/05-other/01-compatibility.mdx b/docs/docs/05-other/01-compatibility.mdx index 31c84ddd62..65271a2cb4 100644 --- a/docs/docs/05-other/01-compatibility.mdx +++ b/docs/docs/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,13 +54,15 @@ React Native ExecuTorch supports only the [New Architecture](https://reactnative
yes
yes
yes
+
untested†
0.10.x
no
no
no
-
yes
+
yes*
+
yes*
yes
yes
yes
@@ -67,3 +71,24 @@ React Native ExecuTorch supports only the [New Architecture](https://reactnative + +
+ +**\*** React Native 0.81 and 0.82 need `react-native-worklets` **0.8.x or +0.9.x** — worklets 0.10, which newer Expo SDKs bundle, itself requires React +Native 0.83+. The library supports the whole `>=0.8.0 <0.11.0` range, with one +difference on 0.8/0.9: those versions serialize an `ArrayBufferView` by +rebuilding it over its **entire** backing buffer, losing `byteOffset` and +`length` ([reanimated +#9475](https://github.com/software-mansion/react-native-reanimated/pull/9475) +fixed that in worklets 0.10.0). A window into a larger buffer — a slice of a +recording, a pooled camera frame — would otherwise reach the model silently +widened, so every value crossing a worklet boundary is checked and any such +view is copied into a tightly sized one first. The copy costs one allocation +per call, and only for a view that is not already tight; on worklets 0.10+ no +copy is ever made. Results are identical either way. + +**†** 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. + +
diff --git a/docs/src/components/CompatibilityTable/styles.module.css b/docs/src/components/CompatibilityTable/styles.module.css index a54f35849d..becc300f75 100644 --- a/docs/src/components/CompatibilityTable/styles.module.css +++ b/docs/src/components/CompatibilityTable/styles.module.css @@ -56,3 +56,22 @@ background-color: var(--swm-red-dark-140); color: var(--swm-red-dark-80); } + +.partial { + background-color: var(--swm-yellow-light-40); + color: var(--swm-yellow-dark-120); + border-radius: 4px; + padding: 4px 8px; + font-weight: 600; + font-size: 0.85rem; +} + +[data-theme='dark'] .partial { + background-color: var(--swm-yellow-dark-140); + color: var(--swm-yellow-dark-80); +} + +.footnote { + margin-top: 0.75rem; + font-size: 0.85rem; +} diff --git a/packages/react-native-executorch/__tests__/core/runtime.test.ts b/packages/react-native-executorch/__tests__/core/runtime.test.ts index e9fe69e7f2..20a793ba9f 100644 --- a/packages/react-native-executorch/__tests__/core/runtime.test.ts +++ b/packages/react-native-executorch/__tests__/core/runtime.test.ts @@ -35,4 +35,41 @@ describe('wrapAsync', () => { const runtime = { name: 'custom' } as never; await expect(wrapAsync(() => 'ok', runtime)()).resolves.toBe('ok'); }); + + // worklets < 0.10.0 rebuilds an ArrayBufferView over its whole backing + // buffer, so a window into a larger one arrives silently widened. wrapAsync + // tightens both directions first — see src/core/serialization.ts. + it('tightens an offset view before it reaches the worklet', async () => { + const recording = new Float32Array(1024); + recording.fill(0.25, 256, 260); + const window = recording.subarray(256, 260); + + const seen = jest.fn((view: Float32Array) => view.length); + await expect(wrapAsync(seen)(window)).resolves.toBe(4); + + const received = seen.mock.calls[0]![0]; + expect(received.length).toBe(4); + expect(received.byteOffset).toBe(0); + expect([...received]).toEqual([0.25, 0.25, 0.25, 0.25]); + }); + + it('tightens an offset view on the way back', async () => { + const audio = new Float32Array(512); + audio.fill(1, 0, 8); + + const trimmed = await wrapAsync(() => audio.subarray(0, 8))(); + + expect(trimmed.length).toBe(8); + expect(trimmed.buffer.byteLength).toBe(8 * Float32Array.BYTES_PER_ELEMENT); + }); + + it('leaves a tight view alone in both directions', async () => { + const tight = new Uint8Array(16); + const echo = jest.fn((view: Uint8Array) => view); + + const returned = await wrapAsync(echo)(tight); + + expect(echo.mock.calls[0]![0]).toBe(tight); + expect(returned).toBe(tight); + }); }); diff --git a/packages/react-native-executorch/__tests__/core/serialization.test.ts b/packages/react-native-executorch/__tests__/core/serialization.test.ts new file mode 100644 index 0000000000..f20f8e3af6 --- /dev/null +++ b/packages/react-native-executorch/__tests__/core/serialization.test.ts @@ -0,0 +1,131 @@ +/** + * Tightening `ArrayBufferView`s before they cross a worklet runtime boundary. + * + * `react-native-worklets` below 0.10.0 rebuilds a view on the target runtime as + * `new Ctor(buffer)`, which spans the whole backing buffer and loses + * `byteOffset` and `length` (reanimated #9475 replaced that with a native call + * carrying both). Measured on a simulator, `new Uint8Array(buffer, 64, 16)` + * arrives with `length` 1024 on worklets 0.8.3 and 16 on 0.10.4. + * + * `src/core/serialization.ts` closes that by copying any non-tight view before + * the hop, in both directions, so a caller can pass a window into a bigger + * buffer on any supported worklets version. These suites pin the two halves of + * that contract: what gets copied, and what must not be. + */ +import { tightenArrayBufferViews } from '../../src/core/serialization'; + +describe('tightenArrayBufferViews', () => { + it('copies a view that does not start at zero', () => { + const buffer = new ArrayBuffer(1024); + new Uint8Array(buffer).fill(7); + const window = new Uint8Array(buffer, 64, 16); + + const tightened = tightenArrayBufferViews(window); + + expect(tightened).not.toBe(window); + expect(tightened.byteOffset).toBe(0); + expect(tightened.length).toBe(16); + expect(tightened.buffer.byteLength).toBe(16); + expect([...tightened]).toEqual([...window]); + }); + + it('copies a view that starts at zero but stops short of the buffer', () => { + // `subarray(0, n)` is the shape Kokoro trims its audio to, and it is just + // as wrong on the old serializer as an offset one: the rebuilt view runs to + // the end of the buffer and picks up whatever follows. + const source = new Float32Array(64).fill(0.5); + const head = source.subarray(0, 8); + + const tightened = tightenArrayBufferViews(head); + + expect(tightened).not.toBe(head); + expect(tightened.length).toBe(8); + expect(tightened.buffer.byteLength).toBe(8 * Float32Array.BYTES_PER_ELEMENT); + }); + + it('returns a view that already spans its buffer unchanged', () => { + // The common case, and the one that must not allocate: every buffer coming + // back from native is tight already. + const tight = new Uint8Array(32); + expect(tightenArrayBufferViews(tight)).toBe(tight); + }); + + it('preserves the view type', () => { + const buffer = new ArrayBuffer(64); + const view = new Int32Array(buffer, 8, 4); + + const tightened = tightenArrayBufferViews(view); + + expect(tightened).toBeInstanceOf(Int32Array); + expect(tightened.length).toBe(4); + }); + + it('tightens a DataView', () => { + const buffer = new ArrayBuffer(64); + new DataView(buffer).setUint16(16, 0xbeef); + const view = new DataView(buffer, 16, 2); + + const tightened = tightenArrayBufferViews(view); + + expect(tightened).toBeInstanceOf(DataView); + expect(tightened.byteOffset).toBe(0); + expect(tightened.byteLength).toBe(2); + expect(tightened.getUint16(0)).toBe(0xbeef); + }); + + it('reaches a view nested in an object, as an ImageBuffer carries one', () => { + const frame = new Uint8Array(new ArrayBuffer(4096), 1024, 12); + const image = { data: frame, width: 2, height: 2, format: 'rgb' }; + + const tightened = tightenArrayBufferViews(image); + + expect(tightened).not.toBe(image); + expect(tightened.data.length).toBe(12); + expect(tightened.data.byteOffset).toBe(0); + expect(tightened.width).toBe(2); + expect(tightened.format).toBe('rgb'); + }); + + it('reaches a view nested in an array, as an args tuple is', () => { + const window = new Float32Array(new ArrayBuffer(256), 32, 4); + + const [tightened, options] = tightenArrayBufferViews([window, { topk: 3 }]); + + expect(tightened.length).toBe(4); + expect(options).toEqual({ topk: 3 }); + }); + + it('leaves a value alone when nothing inside it needs tightening', () => { + // Identity, not just equality: an untouched value is never re-allocated, + // which is what keeps the walk free on the hot path. + const value = { data: new Uint8Array(8), labels: ['cat', 'dog'], topk: 2 }; + expect(tightenArrayBufferViews(value)).toBe(value); + }); + + it('passes class instances through untouched', () => { + // A native handle or a Synchronizable must cross as itself; copying it + // would strip the prototype the other side calls methods on. + class NativeHandle { + constructor(public readonly id: number) {} + dispose() {} + } + const handle = new NativeHandle(1); + + const tightened = tightenArrayBufferViews({ handle }); + + expect(tightened.handle).toBe(handle); + }); + + it('passes primitives and null through', () => { + expect(tightenArrayBufferViews(null)).toBeNull(); + expect(tightenArrayBufferViews(undefined)).toBeUndefined(); + expect(tightenArrayBufferViews(42)).toBe(42); + expect(tightenArrayBufferViews('frame')).toBe('frame'); + }); + + it('terminates on a cyclic value', () => { + const cyclic: Record = { depth: 0 }; + cyclic.self = cyclic; + expect(() => tightenArrayBufferViews(cyclic)).not.toThrow(); + }); +}); diff --git a/packages/react-native-executorch/package.json b/packages/react-native-executorch/package.json index f7ef58035f..3eac2bccae 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" + "react-native-worklets": ">=0.8.0 <0.11.0" }, "peerDependenciesMeta": { "@kesha-antonov/react-native-background-downloader": { diff --git a/packages/react-native-executorch/src/core/runtime.ts b/packages/react-native-executorch/src/core/runtime.ts index 9d41837dc9..8149286d30 100644 --- a/packages/react-native-executorch/src/core/runtime.ts +++ b/packages/react-native-executorch/src/core/runtime.ts @@ -12,6 +12,7 @@ import { type WorkletRuntime, } from 'react-native-worklets'; import { isRnExecuTorchError, RnExecuTorchError } from './error'; +import { tightenArrayBufferViews } from './serialization'; /** * The default background worklet runtime used for all model execution. @@ -61,7 +62,7 @@ export function wrapAsync( (argsArray) => { 'worklet'; try { - return { ok: true, value: fn(...argsArray) }; + return { ok: true, value: tightenArrayBufferViews(fn(...argsArray)) }; } catch (e: any) { // Only plain data survives the hop back to the React Native runtime: // class identity, the prototype chain, and the stack do not. Carry the @@ -80,7 +81,11 @@ export function wrapAsync( return { ok: false, error }; } }, - args + // Both directions are tightened: worklets < 0.10.0 rebuilds a view over + // its whole backing buffer, so an offset window - a slice of a recording, + // a pooled camera frame - would arrive silently widened. See + // `tightenArrayBufferViews`. + tightenArrayBufferViews(args) ); if (!result.ok) { diff --git a/packages/react-native-executorch/src/core/serialization.ts b/packages/react-native-executorch/src/core/serialization.ts new file mode 100644 index 0000000000..551332ce76 --- /dev/null +++ b/packages/react-native-executorch/src/core/serialization.ts @@ -0,0 +1,120 @@ +/** + * Making values safe to send across a worklet runtime boundary. + * + * `react-native-worklets` only learned to serialize an `ArrayBufferView` + * natively in 0.10.0 ([reanimated + * #9475](https://github.com/software-mansion/react-native-reanimated/pull/9475)). + * Before that a view was serialized as an initializer that rebuilt it on the + * target runtime with `new Ctor(buffer)` — over the *whole* backing buffer, + * dropping `byteOffset` and `length`. A caller that hands a pipeline a rolling + * window into a larger buffer, which is the normal shape of streaming audio and + * of pooled camera frames, silently gets the entire buffer instead: no error, + * just the wrong samples or the wrong pixels. + * + * ```ts + * const window = recording.subarray(cursor, cursor + 16_000); + * await detectVoice(window); // worklets <= 0.9 sees all of `recording` + * ``` + * + * So every value that crosses the boundary — the arguments going in, the result + * coming back — is walked first, and any view that is not already tight is + * copied into one that is. A tight view is returned untouched, which is the + * overwhelmingly common case, so the walk normally allocates nothing. + * + * This runs on both sides of the hop, so it carries the `'worklet'` directive. + */ + +/** + * How deep the walk goes before giving up. + * + * Arguments and results are shallow — an `ImageBuffer` and an options object, + * a list of detections — so this is only a backstop against a cyclic or + * pathologically nested value, which would otherwise recurse forever. + */ +const MAX_DEPTH = 8; + +/** + * Whether a view already spans its entire backing buffer. + * + * Only these survive the pre-0.10 serializer unchanged: rebuilding one with + * `new Ctor(buffer)` reproduces it exactly. + * @param view The view to test. + * @returns `true` when the view covers the whole buffer from offset zero. + */ +function isTight(view: ArrayBufferView): boolean { + 'worklet'; + return view.byteOffset === 0 && view.byteLength === view.buffer.byteLength; +} + +/** + * Copies a view into a fresh buffer that it exactly fills. + * @param view The view to copy. + * @returns A view of the same type over its own tightly sized buffer. + */ +function tighten(view: ArrayBufferView): ArrayBufferView { + 'worklet'; + const buffer = view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength); + if (view instanceof DataView) { + return new DataView(buffer); + } + // Every TypedArray constructor accepts a buffer, and the view's own + // constructor is the only thing that knows which one it is. + const constructor = (view as { constructor: unknown }).constructor as new ( + tightBuffer: ArrayBufferLike + ) => ArrayBufferView; + return new constructor(buffer); +} + +/** + * Replaces every offset or partial `ArrayBufferView` in `value` with a tight + * copy, leaving everything else — and every already-tight view — as it is. + * + * Arrays and plain objects are walked; anything with a prototype of its own + * (a native handle, a `Synchronizable`, a class instance) is passed through + * untouched, because copying it would break identity that the other side + * depends on. + * @typeParam T The type of the value being sent. + * @param value The value about to cross a worklet runtime boundary. + * @param depth Current recursion depth. Internal. + * @returns `value` itself when nothing needed tightening, otherwise a copy with + * the offending views replaced. + */ +export function tightenArrayBufferViews(value: T, depth: number = 0): T { + 'worklet'; + if (value === null || typeof value !== 'object') { + return value; + } + + if (ArrayBuffer.isView(value)) { + return (isTight(value) ? value : tighten(value)) as T; + } + + if (depth >= MAX_DEPTH) { + return value; + } + + if (Array.isArray(value)) { + let changed = false; + const next = value.map((entry) => { + const tightened = tightenArrayBufferViews(entry, depth + 1); + changed ||= tightened !== entry; + return tightened; + }); + return (changed ? next : value) as T; + } + + const prototype = Object.getPrototypeOf(value); + if (prototype !== Object.prototype && prototype !== null) { + return value; + } + + let changed = false; + const next: Record = {}; + for (const key of Object.keys(value)) { + const entry = (value as Record)[key]; + const tightened = tightenArrayBufferViews(entry, depth + 1); + changed ||= tightened !== entry; + next[key] = tightened; + } + return (changed ? next : value) as T; +} diff --git a/yarn.lock b/yarn.lock index e40dc3592e..1878e6aa6a 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 + react-native-worklets: ">=0.8.0 <0.11.0" peerDependenciesMeta: "@kesha-antonov/react-native-background-downloader": optional: true From e5ab2d8f08729c5c2460b62beb99e81cd7c66a37 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Wed, 9 Sep 2026 12:59:33 +0200 Subject: [PATCH 2/4] fix(deps): require worklets >=0.10.0 <0.13.0 and document it Supersedes the previous commit on this branch, which widened the peer down to 0.8.0 and copied every ArrayBufferView at the worklet boundary to make those versions behave. That bought only Expo SDK 54, React Native 0.81 and 0.82 - Expo 55 and 56 reach worklets 0.10 by asking for it, since the package version is not tied to the SDK - and it carried compat code for versions that are wrong rather than slow: below 0.10 an offset view arrives widened to its whole backing buffer. So keep 0.10 as the floor and fix the two real problems instead. The upper bound was `^0.10.0`, which excludes worklets 0.11 and 0.12. Those are the current releases, carry the same API with identical signatures, and typecheck clean here, so anyone installing `react-native-worklets` alongside this library met an unnecessary peer warning. And the requirement was never written down: the docs said React Native 0.81+/Expo SDK 54+ while the peer ruled both out, and the install command asks for `react-native-worklets` with no version at all. Getting Started now states the range, explains that worklets 0.10 is what sets the React Native 0.83 floor, and gives SDK 55/56 users the two versions to install (worklets and Reanimated move together). The compatibility table gains a React Native 0.86 column, drops 0.81/0.82 for 0.10.x, and a second table covers Expo SDKs. `__tests__/api/workletsVersionRange.test.ts` ties the range to the installed worklets, to the React Native floor it implies, and to the number in the docs - the pair that drifted in the first place. --- .../01-fundamentals/01-getting-started.md | 27 ++-- docs/docs/05-other/01-compatibility.mdx | 83 +++++++++-- .../api/workletsVersionRange.test.ts | 89 ++++++++++++ .../__tests__/core/runtime.test.ts | 37 ----- .../__tests__/core/serialization.test.ts | 131 ------------------ packages/react-native-executorch/package.json | 2 +- .../src/core/runtime.ts | 9 +- .../src/core/serialization.ts | 120 ---------------- yarn.lock | 2 +- 9 files changed, 180 insertions(+), 320 deletions(-) create mode 100644 packages/react-native-executorch/__tests__/api/workletsVersionRange.test.ts delete mode 100644 packages/react-native-executorch/__tests__/core/serialization.test.ts delete mode 100644 packages/react-native-executorch/src/core/serialization.ts diff --git a/docs/docs/01-fundamentals/01-getting-started.md b/docs/docs/01-fundamentals/01-getting-started.md index 74f6bfb8ca..f4e1ddaa60 100644 --- a/docs/docs/01-fundamentals/01-getting-started.md +++ b/docs/docs/01-fundamentals/01-getting-started.md @@ -84,20 +84,31 @@ pnpm add react-native-executorch react-native-worklets react-native-blob-util React Native ExecuTorch requires: - **New Architecture** enabled -- **React Native 0.81+** with [`react-native-worklets`](https://github.com/software-mansion/react-native-worklets) in the `>=0.8.0 <0.11.0` range, 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) +- **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`), which is what + sets that floor: 0.10 is the first release to serialize an `ArrayBufferView` + natively, and it requires React Native 0.83+ - **iOS 17.0+** / **Android 13+** -React Native 0.81 and 0.82 pin you to worklets 0.8/0.9, which serialize an -`ArrayBufferView` slightly differently — the library works around it, see the -[Compatibility table](../05-other/01-compatibility.mdx). Expo SDK 54 and 55 -bundle worklets 0.5 and 0.7, below the supported range, so on those SDKs you -have to move to 0.8+ yourself; the version Reanimated expects is pinned per SDK, -so check both before upgrading. - For supported React Native versions, see the [Compatibility table](../05-other/01-compatibility.mdx). ::: +:::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. Ask for the versions +this library needs instead, together with the Reanimated release that expects +the same worklets (Reanimated pins it exactly, so bumping one without the other +gives you two incompatible native runtimes): + +```bash +npm install react-native-worklets@^0.10.0 react-native-reanimated@^4.5.0 +``` + +Our own example apps do exactly this. **Expo SDK 54 cannot be supported**: it is +React Native 0.81, below what worklets 0.10 accepts. +::: + ### Selecting native libraries The native binaries — the ExecuTorch hardware backends (XNNPACK, Core ML, MLX, diff --git a/docs/docs/05-other/01-compatibility.mdx b/docs/docs/05-other/01-compatibility.mdx index 65271a2cb4..e99fdbb98a 100644 --- a/docs/docs/05-other/01-compatibility.mdx +++ b/docs/docs/05-other/01-compatibility.mdx @@ -61,8 +61,8 @@ React Native ExecuTorch supports only the [New Architecture](https://reactnative
no
no
no
-
yes*
-
yes*
+
no*
+
no*
yes
yes
yes
@@ -74,21 +74,74 @@ React Native ExecuTorch supports only the [New Architecture](https://reactnative
-**\*** React Native 0.81 and 0.82 need `react-native-worklets` **0.8.x or -0.9.x** — worklets 0.10, which newer Expo SDKs bundle, itself requires React -Native 0.83+. The library supports the whole `>=0.8.0 <0.11.0` range, with one -difference on 0.8/0.9: those versions serialize an `ArrayBufferView` by -rebuilding it over its **entire** backing buffer, losing `byteOffset` and -`length` ([reanimated -#9475](https://github.com/software-mansion/react-native-reanimated/pull/9475) -fixed that in worklets 0.10.0). A window into a larger buffer — a slice of a -recording, a pooled camera frame — would otherwise reach the model silently -widened, so every value crossing a worklet boundary is checked and any such -view is copied into a tightly sized one first. The copy costs one allocation -per call, and only for a view that is not already tight; on worklets 0.10+ no -copy is ever made. Results are identical either way. +**\*** `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 SDKReact NativeBundled workletsreact-native-executorch 0.10.x
54
0.810.5.1
no
55
0.830.7.4
needs explicit versions
56
0.850.8.3
needs explicit versions
57
0.860.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/packages/react-native-executorch/__tests__/api/workletsVersionRange.test.ts b/packages/react-native-executorch/__tests__/api/workletsVersionRange.test.ts new file mode 100644 index 0000000000..56187da90d --- /dev/null +++ b/packages/react-native-executorch/__tests__/api/workletsVersionRange.test.ts @@ -0,0 +1,89 @@ +/** + * The supported `react-native-worklets` range, and the documentation of it. + * + * This range is not a formality. Worklets 0.10.0 is the first release to + * serialize an `ArrayBufferView` natively ([reanimated + * #9475](https://github.com/software-mansion/react-native-reanimated/pull/9475)); + * before it, a view was rebuilt on the target runtime over its whole backing + * buffer, so a window into a larger one — a slice of a recording, a pooled + * camera frame — reached the model silently widened. Measured on a simulator, + * `new Uint8Array(buffer, 64, 16)` arrives with `length` 1024 on worklets 0.8.3 + * and 16 on 0.10.4. + * + * Worklets 0.10 in turn requires React Native 0.83+, so the peer range is also + * what decides which React Native versions this library supports. That is + * exactly the pair that drifted before: the requirements page claimed React + * Native 0.81+ while the peer ruled it out, and nothing noticed. + * + * So the range is checked against three things at once: the version actually + * installed here, the React Native floor it implies, and the number written in + * the docs. + */ +import { readFileSync } from 'fs'; +import { join } from 'path'; + +import { satisfies, minVersion, subset } from 'semver'; + +const PACKAGE_ROOT = join(__dirname, '..', '..'); +const DOCS = join(PACKAGE_ROOT, '..', '..', 'docs', 'docs'); + +const manifest = JSON.parse(readFileSync(join(PACKAGE_ROOT, 'package.json'), 'utf8')); +const range: string = manifest.peerDependencies['react-native-worklets']; + +/** The worklets copy this repo installs, i.e. what the suites run against. */ +const installed = JSON.parse( + readFileSync( + require.resolve('react-native-worklets/package.json', { + paths: [PACKAGE_ROOT], + }), + 'utf8' + ) +); + +describe('react-native-worklets peer range', () => { + it('starts at 0.10.0, where native ArrayBufferView serialization landed', () => { + expect(satisfies('0.9.3', range)).toBe(false); + expect(satisfies('0.10.0', range)).toBe(true); + }); + + 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']) { + 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); + }); + + it('is satisfied by the version installed in this repo', () => { + expect(satisfies(installed.version, range)).toBe(true); + }); + + it('matches the React Native floor the docs promise', () => { + // Worklets declares which React Native versions it accepts; ours cannot be + // lower than what the oldest worklets we allow will run on. + const oldest = minVersion(range)!.version; + const workletsRnRange: string = installed.peerDependencies['react-native']; + const rnFloor = minVersion(workletsRnRange)!.version; + + const gettingStarted = readFileSync( + join(DOCS, '01-fundamentals', '01-getting-started.md'), + 'utf8' + ); + + expect(oldest).toBe('0.10.0'); + // e.g. "React Native 0.83+" + expect(gettingStarted).toContain(`React Native ${rnFloor.split('.').slice(0, 2).join('.')}+`); + }); + + it('is written into the requirements the same way', () => { + const gettingStarted = readFileSync( + join(DOCS, '01-fundamentals', '01-getting-started.md'), + 'utf8' + ); + expect(gettingStarted).toContain(range); + }); +}); diff --git a/packages/react-native-executorch/__tests__/core/runtime.test.ts b/packages/react-native-executorch/__tests__/core/runtime.test.ts index 20a793ba9f..e9fe69e7f2 100644 --- a/packages/react-native-executorch/__tests__/core/runtime.test.ts +++ b/packages/react-native-executorch/__tests__/core/runtime.test.ts @@ -35,41 +35,4 @@ describe('wrapAsync', () => { const runtime = { name: 'custom' } as never; await expect(wrapAsync(() => 'ok', runtime)()).resolves.toBe('ok'); }); - - // worklets < 0.10.0 rebuilds an ArrayBufferView over its whole backing - // buffer, so a window into a larger one arrives silently widened. wrapAsync - // tightens both directions first — see src/core/serialization.ts. - it('tightens an offset view before it reaches the worklet', async () => { - const recording = new Float32Array(1024); - recording.fill(0.25, 256, 260); - const window = recording.subarray(256, 260); - - const seen = jest.fn((view: Float32Array) => view.length); - await expect(wrapAsync(seen)(window)).resolves.toBe(4); - - const received = seen.mock.calls[0]![0]; - expect(received.length).toBe(4); - expect(received.byteOffset).toBe(0); - expect([...received]).toEqual([0.25, 0.25, 0.25, 0.25]); - }); - - it('tightens an offset view on the way back', async () => { - const audio = new Float32Array(512); - audio.fill(1, 0, 8); - - const trimmed = await wrapAsync(() => audio.subarray(0, 8))(); - - expect(trimmed.length).toBe(8); - expect(trimmed.buffer.byteLength).toBe(8 * Float32Array.BYTES_PER_ELEMENT); - }); - - it('leaves a tight view alone in both directions', async () => { - const tight = new Uint8Array(16); - const echo = jest.fn((view: Uint8Array) => view); - - const returned = await wrapAsync(echo)(tight); - - expect(echo.mock.calls[0]![0]).toBe(tight); - expect(returned).toBe(tight); - }); }); diff --git a/packages/react-native-executorch/__tests__/core/serialization.test.ts b/packages/react-native-executorch/__tests__/core/serialization.test.ts deleted file mode 100644 index f20f8e3af6..0000000000 --- a/packages/react-native-executorch/__tests__/core/serialization.test.ts +++ /dev/null @@ -1,131 +0,0 @@ -/** - * Tightening `ArrayBufferView`s before they cross a worklet runtime boundary. - * - * `react-native-worklets` below 0.10.0 rebuilds a view on the target runtime as - * `new Ctor(buffer)`, which spans the whole backing buffer and loses - * `byteOffset` and `length` (reanimated #9475 replaced that with a native call - * carrying both). Measured on a simulator, `new Uint8Array(buffer, 64, 16)` - * arrives with `length` 1024 on worklets 0.8.3 and 16 on 0.10.4. - * - * `src/core/serialization.ts` closes that by copying any non-tight view before - * the hop, in both directions, so a caller can pass a window into a bigger - * buffer on any supported worklets version. These suites pin the two halves of - * that contract: what gets copied, and what must not be. - */ -import { tightenArrayBufferViews } from '../../src/core/serialization'; - -describe('tightenArrayBufferViews', () => { - it('copies a view that does not start at zero', () => { - const buffer = new ArrayBuffer(1024); - new Uint8Array(buffer).fill(7); - const window = new Uint8Array(buffer, 64, 16); - - const tightened = tightenArrayBufferViews(window); - - expect(tightened).not.toBe(window); - expect(tightened.byteOffset).toBe(0); - expect(tightened.length).toBe(16); - expect(tightened.buffer.byteLength).toBe(16); - expect([...tightened]).toEqual([...window]); - }); - - it('copies a view that starts at zero but stops short of the buffer', () => { - // `subarray(0, n)` is the shape Kokoro trims its audio to, and it is just - // as wrong on the old serializer as an offset one: the rebuilt view runs to - // the end of the buffer and picks up whatever follows. - const source = new Float32Array(64).fill(0.5); - const head = source.subarray(0, 8); - - const tightened = tightenArrayBufferViews(head); - - expect(tightened).not.toBe(head); - expect(tightened.length).toBe(8); - expect(tightened.buffer.byteLength).toBe(8 * Float32Array.BYTES_PER_ELEMENT); - }); - - it('returns a view that already spans its buffer unchanged', () => { - // The common case, and the one that must not allocate: every buffer coming - // back from native is tight already. - const tight = new Uint8Array(32); - expect(tightenArrayBufferViews(tight)).toBe(tight); - }); - - it('preserves the view type', () => { - const buffer = new ArrayBuffer(64); - const view = new Int32Array(buffer, 8, 4); - - const tightened = tightenArrayBufferViews(view); - - expect(tightened).toBeInstanceOf(Int32Array); - expect(tightened.length).toBe(4); - }); - - it('tightens a DataView', () => { - const buffer = new ArrayBuffer(64); - new DataView(buffer).setUint16(16, 0xbeef); - const view = new DataView(buffer, 16, 2); - - const tightened = tightenArrayBufferViews(view); - - expect(tightened).toBeInstanceOf(DataView); - expect(tightened.byteOffset).toBe(0); - expect(tightened.byteLength).toBe(2); - expect(tightened.getUint16(0)).toBe(0xbeef); - }); - - it('reaches a view nested in an object, as an ImageBuffer carries one', () => { - const frame = new Uint8Array(new ArrayBuffer(4096), 1024, 12); - const image = { data: frame, width: 2, height: 2, format: 'rgb' }; - - const tightened = tightenArrayBufferViews(image); - - expect(tightened).not.toBe(image); - expect(tightened.data.length).toBe(12); - expect(tightened.data.byteOffset).toBe(0); - expect(tightened.width).toBe(2); - expect(tightened.format).toBe('rgb'); - }); - - it('reaches a view nested in an array, as an args tuple is', () => { - const window = new Float32Array(new ArrayBuffer(256), 32, 4); - - const [tightened, options] = tightenArrayBufferViews([window, { topk: 3 }]); - - expect(tightened.length).toBe(4); - expect(options).toEqual({ topk: 3 }); - }); - - it('leaves a value alone when nothing inside it needs tightening', () => { - // Identity, not just equality: an untouched value is never re-allocated, - // which is what keeps the walk free on the hot path. - const value = { data: new Uint8Array(8), labels: ['cat', 'dog'], topk: 2 }; - expect(tightenArrayBufferViews(value)).toBe(value); - }); - - it('passes class instances through untouched', () => { - // A native handle or a Synchronizable must cross as itself; copying it - // would strip the prototype the other side calls methods on. - class NativeHandle { - constructor(public readonly id: number) {} - dispose() {} - } - const handle = new NativeHandle(1); - - const tightened = tightenArrayBufferViews({ handle }); - - expect(tightened.handle).toBe(handle); - }); - - it('passes primitives and null through', () => { - expect(tightenArrayBufferViews(null)).toBeNull(); - expect(tightenArrayBufferViews(undefined)).toBeUndefined(); - expect(tightenArrayBufferViews(42)).toBe(42); - expect(tightenArrayBufferViews('frame')).toBe('frame'); - }); - - it('terminates on a cyclic value', () => { - const cyclic: Record = { depth: 0 }; - cyclic.self = cyclic; - expect(() => tightenArrayBufferViews(cyclic)).not.toThrow(); - }); -}); diff --git a/packages/react-native-executorch/package.json b/packages/react-native-executorch/package.json index 3eac2bccae..6d9e4852d4 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.8.0 <0.11.0" + "react-native-worklets": ">=0.10.0 <0.13.0" }, "peerDependenciesMeta": { "@kesha-antonov/react-native-background-downloader": { diff --git a/packages/react-native-executorch/src/core/runtime.ts b/packages/react-native-executorch/src/core/runtime.ts index 8149286d30..9d41837dc9 100644 --- a/packages/react-native-executorch/src/core/runtime.ts +++ b/packages/react-native-executorch/src/core/runtime.ts @@ -12,7 +12,6 @@ import { type WorkletRuntime, } from 'react-native-worklets'; import { isRnExecuTorchError, RnExecuTorchError } from './error'; -import { tightenArrayBufferViews } from './serialization'; /** * The default background worklet runtime used for all model execution. @@ -62,7 +61,7 @@ export function wrapAsync( (argsArray) => { 'worklet'; try { - return { ok: true, value: tightenArrayBufferViews(fn(...argsArray)) }; + return { ok: true, value: fn(...argsArray) }; } catch (e: any) { // Only plain data survives the hop back to the React Native runtime: // class identity, the prototype chain, and the stack do not. Carry the @@ -81,11 +80,7 @@ export function wrapAsync( return { ok: false, error }; } }, - // Both directions are tightened: worklets < 0.10.0 rebuilds a view over - // its whole backing buffer, so an offset window - a slice of a recording, - // a pooled camera frame - would arrive silently widened. See - // `tightenArrayBufferViews`. - tightenArrayBufferViews(args) + args ); if (!result.ok) { diff --git a/packages/react-native-executorch/src/core/serialization.ts b/packages/react-native-executorch/src/core/serialization.ts deleted file mode 100644 index 551332ce76..0000000000 --- a/packages/react-native-executorch/src/core/serialization.ts +++ /dev/null @@ -1,120 +0,0 @@ -/** - * Making values safe to send across a worklet runtime boundary. - * - * `react-native-worklets` only learned to serialize an `ArrayBufferView` - * natively in 0.10.0 ([reanimated - * #9475](https://github.com/software-mansion/react-native-reanimated/pull/9475)). - * Before that a view was serialized as an initializer that rebuilt it on the - * target runtime with `new Ctor(buffer)` — over the *whole* backing buffer, - * dropping `byteOffset` and `length`. A caller that hands a pipeline a rolling - * window into a larger buffer, which is the normal shape of streaming audio and - * of pooled camera frames, silently gets the entire buffer instead: no error, - * just the wrong samples or the wrong pixels. - * - * ```ts - * const window = recording.subarray(cursor, cursor + 16_000); - * await detectVoice(window); // worklets <= 0.9 sees all of `recording` - * ``` - * - * So every value that crosses the boundary — the arguments going in, the result - * coming back — is walked first, and any view that is not already tight is - * copied into one that is. A tight view is returned untouched, which is the - * overwhelmingly common case, so the walk normally allocates nothing. - * - * This runs on both sides of the hop, so it carries the `'worklet'` directive. - */ - -/** - * How deep the walk goes before giving up. - * - * Arguments and results are shallow — an `ImageBuffer` and an options object, - * a list of detections — so this is only a backstop against a cyclic or - * pathologically nested value, which would otherwise recurse forever. - */ -const MAX_DEPTH = 8; - -/** - * Whether a view already spans its entire backing buffer. - * - * Only these survive the pre-0.10 serializer unchanged: rebuilding one with - * `new Ctor(buffer)` reproduces it exactly. - * @param view The view to test. - * @returns `true` when the view covers the whole buffer from offset zero. - */ -function isTight(view: ArrayBufferView): boolean { - 'worklet'; - return view.byteOffset === 0 && view.byteLength === view.buffer.byteLength; -} - -/** - * Copies a view into a fresh buffer that it exactly fills. - * @param view The view to copy. - * @returns A view of the same type over its own tightly sized buffer. - */ -function tighten(view: ArrayBufferView): ArrayBufferView { - 'worklet'; - const buffer = view.buffer.slice(view.byteOffset, view.byteOffset + view.byteLength); - if (view instanceof DataView) { - return new DataView(buffer); - } - // Every TypedArray constructor accepts a buffer, and the view's own - // constructor is the only thing that knows which one it is. - const constructor = (view as { constructor: unknown }).constructor as new ( - tightBuffer: ArrayBufferLike - ) => ArrayBufferView; - return new constructor(buffer); -} - -/** - * Replaces every offset or partial `ArrayBufferView` in `value` with a tight - * copy, leaving everything else — and every already-tight view — as it is. - * - * Arrays and plain objects are walked; anything with a prototype of its own - * (a native handle, a `Synchronizable`, a class instance) is passed through - * untouched, because copying it would break identity that the other side - * depends on. - * @typeParam T The type of the value being sent. - * @param value The value about to cross a worklet runtime boundary. - * @param depth Current recursion depth. Internal. - * @returns `value` itself when nothing needed tightening, otherwise a copy with - * the offending views replaced. - */ -export function tightenArrayBufferViews(value: T, depth: number = 0): T { - 'worklet'; - if (value === null || typeof value !== 'object') { - return value; - } - - if (ArrayBuffer.isView(value)) { - return (isTight(value) ? value : tighten(value)) as T; - } - - if (depth >= MAX_DEPTH) { - return value; - } - - if (Array.isArray(value)) { - let changed = false; - const next = value.map((entry) => { - const tightened = tightenArrayBufferViews(entry, depth + 1); - changed ||= tightened !== entry; - return tightened; - }); - return (changed ? next : value) as T; - } - - const prototype = Object.getPrototypeOf(value); - if (prototype !== Object.prototype && prototype !== null) { - return value; - } - - let changed = false; - const next: Record = {}; - for (const key of Object.keys(value)) { - const entry = (value as Record)[key]; - const tightened = tightenArrayBufferViews(entry, depth + 1); - changed ||= tightened !== entry; - next[key] = tightened; - } - return (changed ? next : value) as T; -} diff --git a/yarn.lock b/yarn.lock index 1878e6aa6a..9ce1d3e437 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.8.0 <0.11.0" + react-native-worklets: ">=0.10.0 <0.13.0" peerDependenciesMeta: "@kesha-antonov/react-native-background-downloader": optional: true From 2e9d6e91ac2f59390d2487cfe22f4932f9ede09e Mon Sep 17 00:00:00 2001 From: Mateusz Sluszniak <56299341+msluszniak@users.noreply.github.com> Date: Wed, 9 Sep 2026 14:57:30 +0200 Subject: [PATCH 3/4] Update docs/docs/01-fundamentals/01-getting-started.md Co-authored-by: Bartosz Hanc --- docs/docs/01-fundamentals/01-getting-started.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/docs/docs/01-fundamentals/01-getting-started.md b/docs/docs/01-fundamentals/01-getting-started.md index f4e1ddaa60..0e2cdd6ccd 100644 --- a/docs/docs/01-fundamentals/01-getting-started.md +++ b/docs/docs/01-fundamentals/01-getting-started.md @@ -85,9 +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`), which is what - sets that floor: 0.10 is the first release to serialize an `ArrayBufferView` - natively, and it requires React Native 0.83+ +- **`react-native-worklets` 0.10 or newer** (`>=0.10.0 <0.13.0`) - **iOS 17.0+** / **Android 13+** For supported React Native versions, see the [Compatibility From 1625be675613c043ad01dd23fd06830b47911fdb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20S=C5=82uszniak?= Date: Wed, 9 Sep 2026 15:24:50 +0200 Subject: [PATCH 4/4] docs: trim the worklets requirement and generalise the version advice Applies review feedback: the requirement bullet states the range and nothing else, and the Expo caution no longer names Reanimated, since any package that uses worklets has to land on the same version. --- docs/docs/01-fundamentals/01-getting-started.md | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/docs/docs/01-fundamentals/01-getting-started.md b/docs/docs/01-fundamentals/01-getting-started.md index 0e2cdd6ccd..9a93c66385 100644 --- a/docs/docs/01-fundamentals/01-getting-started.md +++ b/docs/docs/01-fundamentals/01-getting-started.md @@ -94,17 +94,18 @@ table](../05-other/01-compatibility.mdx). :::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. Ask for the versions -this library needs instead, together with the Reanimated release that expects -the same worklets (Reanimated pins it exactly, so bumping one without the other -gives you two incompatible native runtimes): +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 react-native-reanimated@^4.5.0 +npm install react-native-worklets@^0.10.0 ``` -Our own example apps do exactly this. **Expo SDK 54 cannot be supported**: it is -React Native 0.81, below what worklets 0.10 accepts. +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