npx expo run:ios fails on Xcode 26.2 and 26.3 with 'RuntimeScheduler cannot be annotated with SWIFT_RETURNS_RETAINED' and 'sending resultPtr risks causing data races'. Here is why it happens and a patch-package fix that works today.
September 14, 2026 · Thomino
If you updated to the latest Expo SDK 57 packages and your local iOS build suddenly stopped working on Xcode 26, this is the post for you. Cloud builds on EAS still pass, the app bundles fine in Metro, npx expo-doctor is green, and yet npx expo run:ios dies inside the Build ExpoModulesJSI xcframework phase.
I hit this on September 12, 2026 while updating all 21 of our templates to Expo 57.0.22. Below is what breaks, why, and the patch we now ship with every template. It is a two-file change and takes about five minutes.
The first thing you see is a pair of C++ errors in a header:
node_modules/expo-modules-jsi/apple/Sources/ExpoModulesJSI-Cxx/include/RuntimeScheduler.h:53:26'RuntimeScheduler' cannot be annotated with either SWIFT_RETURNS_RETAINED orSWIFT_RETURNS_UNRETAINED because it is not returning a SWIFT_SHARED_REFERENCE type
Fix that one alone and a second batch shows up, this time in Swift:
node_modules/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift:193:43sending 'resultPtr' risks causing data racessending 'thisPtr' risks causing data racessending 'argumentsPtr' risks causing data races
Seven errors total, then:
CommandError: Failed to build iOS project. "xcodebuild" exited with error code 65.
The second batch is hidden behind the first, which is why most GitHub reports only mention the header error and why the obvious one-line fix leaves you with a build that still fails.
Both errors come from expo-modules-jsi, the package that lets Expo's Swift modules talk to JSI directly. Expo SDK 57.0.22 pins expo-modules-core 57.0.18, which pins expo-modules-jsi 57.1.0. You cannot downgrade just the one package without also downgrading Expo itself.
Error 1: the header. In expo-modules-jsi 57.0.5, released August 20, 2026, Expo added SWIFT_RETURNS_RETAINED to the two RuntimeScheduler constructors. The change came from expo/expo#49120 and was made to silence new ownership warnings in the Xcode 27 beta. The problem is that Swift 6.2, which ships with Xcode 26.2 and 26.3, treats that annotation on a constructor as a hard error. So the fix for the beta broke the current stable toolchain.
Error 2: the Swift file.expo-modules-jsi 57.1.0, released September 8, 2026, rewrote the host-function call path for performance. The result value is now written directly into the caller's slot from inside a @JavaScriptActor closure, and the code relies on nonisolated(unsafe) locals to satisfy strict concurrency. Swift 6.3 accepts that. Swift 6.2.x does not honour it for these captures and reports the pointers as unsafe sends.
If you are on expo-modules-jsi 57.0.5 through 57.0.8 you only have the first error. On 57.1.0 you have both.
Expo knows. The header issue has been reported at least seven times since August 23, 2026, and the open ticket is expo/expo#50067. At the time of writing the file on Expo's main branch still has the same code, so there is nothing to expo install --fix your way to.
You build locally with npx expo run:ios, npx expo prebuild plus Xcode, or a dev client, on Xcode 26.2 or 26.3.
You are on Expo SDK 57 with expo-modules-jsi 57.0.5 or newer.
Not affected: EAS Build (its images use a different Xcode), Android builds, Expo Go, and Metro bundling in general. That is why expo-doctor, TypeScript, and expo export all pass while the native build fails.
Create a patches folder in your project root and save this file into it as patches/expo-modules-jsi+57.1.0.patch. You can also download the patch file directly.
The file name matters: patch-package matches it against the installed package version. If you are on 57.0.8, rename it to expo-modules-jsi+57.0.8.patch and keep only the first hunk (the header change).
diff --git a/node_modules/expo-modules-jsi/apple/Sources/ExpoModulesJSI-Cxx/include/RuntimeScheduler.h b/node_modules/expo-modules-jsi/apple/Sources/ExpoModulesJSI-Cxx/include/RuntimeScheduler.h--- a/node_modules/expo-modules-jsi/apple/Sources/ExpoModulesJSI-Cxx/include/RuntimeScheduler.h+++ b/node_modules/expo-modules-jsi/apple/Sources/ExpoModulesJSI-Cxx/include/RuntimeScheduler.h@@ -45,12 +45,22 @@ private: std::atomic<int> refCount{1}; public:+ // Xcode 27 (clang build 18xxxxxx+) warns when the constructors of a+ // SWIFT_SHARED_REFERENCE type are not annotated; Xcode 26.x (Swift 6.2)+ // rejects the annotation on constructors as an error. Apply it only on+ // toolchains that understand it. See https://github.com/expo/expo/issues/50067+#if defined(__apple_build_version__) && __apple_build_version__ >= 18000000+#define EXPO_RUNTIME_SCHEDULER_CTOR_RETAINED SWIFT_RETURNS_RETAINED+#else+#define EXPO_RUNTIME_SCHEDULER_CTOR_RETAINED+#endif+ /** Constructs a scheduler bound to a host-provided native RuntimeScheduler. `scheduleTask` dispatches through `fn`, which the host implements against the real react::RuntimeScheduler. */- SWIFT_RETURNS_RETAINED RuntimeScheduler(void *scheduler, ScheduleFn fn) noexcept+ EXPO_RUNTIME_SCHEDULER_CTOR_RETAINED RuntimeScheduler(void *scheduler, ScheduleFn fn) noexcept : nativeScheduler(scheduler), scheduleFn(fn) {} /**@@ -58,7 +68,7 @@ public: caller's thread — intended for standalone runtimes (e.g. tests) that have no React scheduler. */- SWIFT_RETURNS_RETAINED RuntimeScheduler() {}+ EXPO_RUNTIME_SCHEDULER_CTOR_RETAINED RuntimeScheduler() {} RuntimeScheduler(const RuntimeScheduler &) = delete;diff --git a/node_modules/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift b/node_modules/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift--- a/node_modules/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift+++ b/node_modules/expo-modules-jsi/apple/Sources/ExpoModulesJSI/Runtime/JavaScriptRuntime.swift@@ -4,6 +4,22 @@ internal import ExpoModulesJSI_Cxx import Foundation internal import jsi+/// Carries a call-scoped raw pointer into the `@JavaScriptActor` closures below.+///+/// Upstream marks these locals `nonisolated(unsafe)`, which Swift 6.3 (Xcode 27) accepts but+/// Swift 6.2.x (Xcode 26) still rejects with "sending 'ptr' risks causing data races". A+/// `@unchecked Sendable` box is exempt from region checking on both compilers. The pointers+/// are read-only, call-scoped inputs consumed synchronously inside `assumeIsolated`, so this+/// is exactly as sound as the upstream annotation. See https://github.com/expo/expo/issues/50067+internal struct UnsafeSendableBox<Value>: @unchecked Sendable {+ let value: Value++ @inline(__always)+ init(_ value: Value) {+ self.value = value+ }+}+ /// A Swift wrapper around a JavaScript runtime. Provides access to a JavaScript execution environment, allowing you to evaluate /// JavaScript code, create and manipulate JavaScript objects, functions, and values, and bridge between Swift and JavaScript. ///@@ -185,12 +201,12 @@ open class JavaScriptRuntime: Equatable, Identifiable, @unchecked Sendable { resultPtr: UnsafeMutablePointer<facebook.jsi.Value> ) -> Bool { let propertyName = String(cString: propertyName)- nonisolated(unsafe) let resultPtr = resultPtr+ let resultPtr = UnsafeSendableBox(resultPtr) return withGuaranteedContext(context) { (context: HostObjectContext, runtime) in return JavaScriptActor.assumeIsolated { return forwardingSwiftErrorsToJS(runtime: runtime) {- try context.get(propertyName).writeJSIValue(to: resultPtr)+ try context.get(propertyName).writeJSIValue(to: resultPtr.value) } } }@@ -774,19 +790,19 @@ private func createFunctionClosure( // synchronous call, so the `nonisolated(unsafe)` capture is sound. This removes a per-call class // allocation + retain/release + dealloc that profiling showed dominating the no-op `@JS` host-call // floor.- nonisolated(unsafe) let thisPtr = thisPtr- nonisolated(unsafe) let argumentsPtr = argumentsPtr- nonisolated(unsafe) let resultPtr = resultPtr+ let thisPtr = UnsafeSendableBox(thisPtr)+ let argumentsPtr = UnsafeSendableBox(argumentsPtr)+ let resultPtr = UnsafeSendableBox(resultPtr) // See `withGuaranteedContext` for why neither the context nor the runtime is retained here, and // why the result is written to the caller's slot instead of being returned. return withGuaranteedContext(context) { (context: HostFunctionContext, runtime) in return JavaScriptActor.assumeIsolated { return forwardingSwiftErrorsToJS(runtime: runtime) {- let this = UnsafeMutablePointer(mutating: thisPtr).move()- let arguments = JavaScriptValuesBuffer(runtime, start: argumentsPtr, count: argumentsCount)+ let this = UnsafeMutablePointer(mutating: thisPtr.value).move()+ let arguments = JavaScriptValuesBuffer(runtime, start: argumentsPtr.value, count: argumentsCount) let thisValue = JavaScriptValue(runtime, this)- try context.call(thisValue, consume arguments).writeJSIValue(to: resultPtr)+ try context.call(thisValue, consume arguments).writeJSIValue(to: resultPtr.value) } } }@@ -817,18 +833,18 @@ private func createFunctionClosure( // handed in as a borrowed `JavaScriptUnownedValue` pointing straight at the C++-owned `this` slot: // it is not moved out and no owning `JavaScriptValue` is allocated, so the closure avoids the // per-call `weak`-runtime form/destroy and heap object that the owning `this` pays.- nonisolated(unsafe) let thisPtr = thisPtr- nonisolated(unsafe) let argumentsPtr = argumentsPtr- nonisolated(unsafe) let resultPtr = resultPtr+ let thisPtr = UnsafeSendableBox(thisPtr)+ let argumentsPtr = UnsafeSendableBox(argumentsPtr)+ let resultPtr = UnsafeSendableBox(resultPtr) // See `withGuaranteedContext` for why neither the context nor the runtime is retained here, and // why the result is written to the caller's slot instead of being returned. return withGuaranteedContext(context) { (context: UnownedThisHostFunctionContext, runtime) in return JavaScriptActor.assumeIsolated { return forwardingSwiftErrorsToJS(runtime: runtime) {- let arguments = JavaScriptValuesBuffer(runtime, start: argumentsPtr, count: argumentsCount)- let thisValue = JavaScriptUnownedValue(runtime.pointee, thisPtr)- try context.call(thisValue, consume arguments).writeJSIValue(to: resultPtr)+ let arguments = JavaScriptValuesBuffer(runtime, start: argumentsPtr.value, count: argumentsCount)+ let thisValue = JavaScriptUnownedValue(runtime.pointee, thisPtr.value)+ try context.call(thisValue, consume arguments).writeJSIValue(to: resultPtr.value) } } }
Clearing the two folders matters. The build phase caches the compiled xcframework per source hash, and the first failed attempt can leave a stub behind that hides the fact your patch was applied.
You should see patch-package report the patch as applied during install, then a normal build:
If your ios folder is old, pod install may fail for unrelated reasons before it even gets to the build. Regenerate it first with npx expo prebuild --clean --platform ios and try again.
The header change keeps Expo's Xcode 27 fix and makes it conditional. __apple_build_version__ is Apple's clang build number: Xcode 26.2 reports 17000603, and Xcode 27 reports 18 million and up. The macro applies SWIFT_RETURNS_RETAINED only where the compiler understands it. Both toolchains build.
The Swift change replaces the three nonisolated(unsafe) locals with a tiny @unchecked Sendable wrapper. Swift's region-based isolation checker skips Sendable values entirely, so the pointer can cross into the actor closure on Swift 6.2 and 6.3 alike. The pointers are call-scoped inputs consumed synchronously inside assumeIsolated, which is exactly what Expo's own comment in that file says, so this is the same safety contract expressed in a form the older compiler accepts. No runtime behaviour changes.
Keep an eye on expo/expo#50067. Once Expo publishes an expo-modules-jsi release that compiles on stable Xcode, patch-package will warn that the patch no longer matches the installed version. At that point delete the patches folder, drop the postinstall script and the patch-package dev dependency, and run npm install.
Build on EAS.eas build --platform ios works today because the build images pin a different Xcode. Fine if you were going to use EAS anyway, but it does not fix your local dev client.
Wait. Expo will fix it. The question is whether you can afford to not build for iOS locally until they do.
Do not try to force expo-modules-jsi back to 57.0.4 with an npm override. It predates the API that expo-modules-core 57.0.17 and newer expect, and you will trade a compiler error for a linker error.
We ship this patch in all of our React Native templates so a fresh clone builds on the current Xcode out of the box. Every template is on Expo SDK 57.0.22 as of September 12, 2026, with a patches/README.md explaining when it can be dropped.