| Item | Version |
|---|---|
| Expo SDK | 55 |
| React Native | 0.83.6 |
| expo-modules-core | 55.0.25 |
| react-native-webview | 13.16.1 |
| Xcode | 16 |
| Swift compiler | Swift 6 (bundled with Xcode 16) |
| macOS | Sonoma (Darwin 23.6.0) |
Homebrew's cocoapods formula depends on its own ruby formula. As of May 2026,
Homebrew's default Ruby is 4.0.x. Ruby 4.0 removed many gems that were previously
part of the standard library:
mutex_mminitestbigdecimalbase64kconv(not published as a standalone gem — no fix available)
CocoaPods 1.16.2's dependencies (activesupport, httpclient, xcodeproj) assume
these exist in stdlib. The result is a cascade of LoadError: cannot load such file
errors each time a new missing gem is discovered. The cascade terminates fatally at
kconv which cannot be installed at all.
Additionally, RVM's Ruby 3.2.1 (present on this machine) was built without OpenSSL,
so gem install over HTTPS fails there too.
Unable to resolve dependency: 'activesupport (>= 5.0, < 8)' requires 'base64 (>= 0)'
→ gem install base64
Unicode Normalization not appropriate for ASCII-8BIT (Encoding::CompatibilityError)
→ LANG=en_US.UTF-8 pod install
LoadError - cannot load such file -- kconv
→ no fix — kconv is not a standalone gem
Step 1: Install Ruby 3.3 via Homebrew
brew install ruby@3.3This installs to /opt/homebrew/opt/ruby@3.3/. It is keg-only (not linked into PATH
by default).
Step 2: Install CocoaPods gem under Ruby 3.3
PATH="/opt/homebrew/opt/ruby@3.3/bin:$PATH" gem install cocoapods --no-documentRubyGems installs to the user gem dir: ~/.local/share/gem/ruby/3.3.0/.
The pod binary lands at ~/.local/share/gem/ruby/3.3.0/bin/pod.
Step 3: Remove the broken Homebrew CocoaPods formula
brew remove cocoapods
# Homebrew auto-removes ruby 4.0 as now-unneeded dependencyStep 4: Run pod install going forward
LANG=en_US.UTF-8 PATH="/opt/homebrew/opt/ruby@3.3/bin:$PATH" \
~/.local/share/gem/ruby/3.3.0/bin/pod installOr add permanently to ~/.zshrc:
export PATH="/opt/homebrew/opt/ruby@3.3/bin:$HOME/.local/share/gem/ruby/3.3.0/bin:$PATH"After that, plain pod install works.
Created example/ios/Gemfile to pin the CocoaPods version used by the project:
source 'https://rubygems.org'
gem 'cocoapods', '~> 1.16'npx expo run:iosdoes NOT re-run pod install automatically ifPods/already exists and is up to date. You only need to re-run pod install when the Podfile changes or afterpod deintegrate/--cleanoperations.npx expo prebuild --cleanDOES regenerate the Podfile from Expo's template and runs pod install. It would overwrite any manual Podfile edits (see Problem 2 below).
Xcode 16 ships with the Swift 6 compiler. expo-modules-core 55.x was written
targeting Swift 5.9/5.10 concurrency semantics and uses patterns that are rejected in
Swift 6 strict mode.
Additionally, Expo's autolinking post_install hook explicitly sets
SWIFT_VERSION = 6 for ExpoModulesCore, Expo, and ExpoModulesJSI pod targets.
In Swift 6 language mode, strict concurrency is a language-level feature — the
SWIFT_STRICT_CONCURRENCY build setting has no effect in Swift 6 mode.
Two distinct categories of errors were observed, driven by two different root causes:
Kind A — strict concurrency violations (21 errors):
These appear because ExpoModulesCore is compiled in Swift 6 mode (Expo sets
SWIFT_VERSION = 6), and Swift 6 enforces actor isolation at the language level.
Examples:
call to main actor-isolated initializer 'init' in a synchronous nonisolated context
main actor-isolated property 'props' can not be referenced from a nonisolated context
main actor-isolated instance method 'X' cannot be used to satisfy nonisolated protocol requirement
stored property of 'Sendable'-conforming class has non-sendable type
Kind B — invalid @MainActor conformance syntax (3 errors):
expo-modules-core 55.0.25 uses a @MainActor placement syntax in 3 files that is
not valid in any shipping Swift version (neither Swift 5 nor Swift 6):
extension UIView: @MainActor AnyArgument { } // ViewDefinition.swift:121
extension ExpoSwiftUI.SwiftUIVirtualView: @MainActor ExpoSwiftUI.ViewWrapper { } // SwiftUIVirtualView.swift:196
class HostingView<…>: ExpoView, @MainActor AnyExpoSwiftUIHostingView { } // SwiftUIHostingView.swift:45Error: unknown attribute 'MainActor'
Placing @MainActor directly on a specific protocol in a conformance/inheritance
list (rather than on the whole type or extension) is a syntax that was experimented
with in Swift 6 pre-releases but was not included in any final released Swift version.
It is rejected by the Swift 6 compiler in both Swift 5 and Swift 6 language modes.
| Attempt | Result |
|---|---|
SWIFT_STRICT_CONCURRENCY = minimal for all targets |
24 → 3 errors. Suppresses Kind A errors in Swift 5 targets, but ExpoModulesCore (Swift 6 mode) ignores it — Swift 6 strict concurrency is language-level |
+ SWIFT_VERSION = '5.0' for all targets |
3 errors. Overrides ExpoModulesCore to Swift 5 so minimal works, but Swift 5.0 predates @MainActor (Swift 5.5) → Kind B errors remain |
+ SWIFT_VERSION = '5.9' for all targets |
3 errors. Same result — 5.9 is not a meaningful distinct value; Xcode treats all 5.x as Swift 5 language mode |
Remove SWIFT_VERSION override (keep only minimal) |
24 errors. ExpoModulesCore reverts to Expo's Swift 6 setting; minimal is ineffective again |
Key insight: there is no single SWIFT_VERSION value that simultaneously:
- Enables
@MainActor(requires ≥ 5.5) - Allows the
@MainActor-on-conformance syntax in the 3 problematic files (not valid in any released version) - Allows
SWIFT_STRICT_CONCURRENCY = minimalto suppress the remaining concurrency errors (only works in Swift 5 mode)
Install patch-package at the workspace root and remove @MainActor from the
three conformance positions. Since UIView, SwiftUIVirtualView, and HostingView are all
UIKit subclasses (inherently main-thread), removing @MainActor from the protocol
conformance position does not change runtime behaviour.
Files patched (patch stored at patches/expo-modules-core+55.0.25.patch):
node_modules/expo-modules-core/ios/Core/Views/ViewDefinition.swift:121
Before: extension UIView: @MainActor AnyArgument {
After: extension UIView: AnyArgument {
node_modules/expo-modules-core/ios/Core/Views/SwiftUI/SwiftUIVirtualView.swift:196
Before: extension ExpoSwiftUI.SwiftUIVirtualView: @MainActor ExpoSwiftUI.ViewWrapper {
After: extension ExpoSwiftUI.SwiftUIVirtualView: ExpoSwiftUI.ViewWrapper {
node_modules/expo-modules-core/ios/Core/Views/SwiftUI/SwiftUIHostingView.swift:45
Before: …: ExpoView, @MainActor AnyExpoSwiftUIHostingView {
After: …: ExpoView, AnyExpoSwiftUIHostingView {
Setup:
# Install patch-package
npm install --save-dev patch-package
# After editing the 3 files:
npx patch-package expo-modules-coreAdd to package.json scripts so the patch re-applies automatically after npm install:
"postinstall": "patch-package"Expo's autolinking hook sets SWIFT_VERSION = 6 for ExpoModulesCore, Expo, and
ExpoModulesJSI. In Swift 6 mode the SWIFT_STRICT_CONCURRENCY = minimal setting is
silently ignored. The post_install block overrides those three targets back to Swift
5 so the minimal flag takes effect.
post_install do |installer|
react_native_post_install(
installer,
config[:reactNativePath],
:mac_catalyst_enabled => false,
:ccache_enabled => ccache_enabled?(podfile_properties),
)
# expo-modules-core uses @MainActor conformance patterns that Xcode 16 / Swift 6
# strict concurrency mode rejects. 'minimal' restores Swift 5 behaviour.
# Expo sets SWIFT_VERSION = 6 for ExpoModulesCore/Expo/ExpoModulesJSI, but Swift 6
# ignores SWIFT_STRICT_CONCURRENCY = minimal. Override those back to Swift 5 so the
# minimal flag takes effect. The 3 source files that used Swift-6-only @MainActor
# conformance syntax are patched via patches/expo-modules-core+55.0.25.patch.
expo_swift6_targets = %w[Expo ExpoModulesCore ExpoModulesJSI]
installer.pods_project.targets.each do |target|
target.build_configurations.each do |config|
config.build_settings['SWIFT_STRICT_CONCURRENCY'] = 'minimal'
if expo_swift6_targets.include?(target.name)
config.build_settings['SWIFT_VERSION'] = '5'
end
end
end
end# 1. Install patch-package (if not already in devDependencies)
npm install
# 2. The postinstall script auto-applies patches/expo-modules-core+55.0.25.patch.
# Verify the 3 files were patched:
grep "extension UIView: AnyArgument" node_modules/expo-modules-core/ios/Core/Views/ViewDefinition.swift
# 3. Run pod install with Ruby 3.3
cd example/ios
LANG=en_US.UTF-8 PATH="/opt/homebrew/opt/ruby@3.3/bin:$PATH" \
~/.local/share/gem/ruby/3.3.0/bin/pod install
cd ..
# 4. Clear DerivedData
rm -rf ~/Library/Developer/Xcode/DerivedData/
# 5. Build
npx expo run:iosAfter fixing the Swift build errors the iOS app launched, but the WebView showed "Error loading page" instead of the code editor.
Root cause 1: Assets folder not in Xcode's Copy Bundle Resources
expo prebuild runs the Expo config plugin (copy-assets-plugin.js) which copies the
src/assets/ tree into ios/CodeditorExample/assets/codeditor/ on disk. However,
copying files to disk is not enough — Xcode must also know about the folder. The file
reference must be added to project.pbxproj as a folder reference in PBXFileReference
and wired into the PBXResourcesBuildPhase, otherwise Xcode ignores the files and they
are not included in the .app bundle.
Symptoms: editor.html physically present on disk at
ios/CodeditorExample/assets/codeditor/editor.html but WKWebView returns a "file not
found" error when trying to load it.
Root cause 2: Invalid iOS URI in the React Native component
The default editorUri in CodeEditor.tsx is "file:///android_asset/editor.html" —
an Android-only path. WKWebView on iOS cannot resolve this URI.
The correct iOS URI uses expo-file-system's bundleDirectory property, which gives
the file:// path to the running .app bundle at runtime.
The copy-assets-plugin.js was updated to use withXcodeProject (from
@expo/config-plugins) so that every expo prebuild both:
- Copies the
src/assets/tree toios/<ProjectName>/assets/codeditor/on disk - Adds the
assetsfolder as a folder reference inproject.pbxproj:- Creates a
PBXFileReferencewithlastKnownFileType = folder - Adds it to the main app PBXGroup (
<ProjectName>) - Creates a
PBXBuildFileand appends it toPBXResourcesBuildPhase
- Creates a
The plugin is idempotent — if a folder reference with name = assets and
lastKnownFileType = folder already exists, it skips the step.
For the current project, the folder reference was also added directly to
project.pbxproj once using the xcode npm package (since expo prebuild was not
re-run). Future prebuilds will maintain it automatically via the plugin.
File reference entry added:
7C3D5FB74E25417483E50113 /* assets */ = {
isa = PBXFileReference;
lastKnownFileType = folder;
name = assets;
path = CodeditorExample/assets;
sourceTree = "<group>";
};
Critical:
pathmust be<ProjectName>/assets(e.g.CodeditorExample/assets), not bareassets. All PBXFileReferences in this project use paths relative to the Xcode project root (ios/). A barepath = assetsmakes Xcode look forios/assetswhich does not exist and causes a build error:error: /path/to/ios/assets: No such file or directory
Added expo-file-system to the example's dependencies (version ~55.0.20 for Expo
SDK 55). The correct URI is derived at runtime:
import * as FileSystem from 'expo-file-system';
// bundleDirectory gives file:// path to the .app bundle folder (iOS only)
const IOS_EDITOR_URI = Platform.OS === 'ios'
? (FileSystem.bundleDirectory ?? '') + 'assets/codeditor/editor.html'
: undefined;This is then passed to <CodeEditor editorUri={IOS_EDITOR_URI} />. When editorUri
is undefined (Android), CodeEditor falls back to "file:///android_asset/editor.html".
Why bundleDirectory?
WKWebView requires an absolute file:// URL to load local HTML. The .app bundle
path is not fixed — it changes with each Xcode build (the GUID in
DerivedData/Build/Products/Debug-iphoneos/<AppName>-<guid>.app). bundleDirectory
resolves to the actual runtime path, making this work on both simulator and real devices.
Path derivation:
- Assets are copied to:
ios/CodeditorExample/assets/codeditor/ - Xcode bundles them as:
MyApp.app/assets/codeditor/ FileSystem.bundleDirectoryreturns:file:///path/to/MyApp.app/- Full URI:
file:///path/to/MyApp.app/assets/codeditor/editor.html
| File | Change |
|---|---|
example/copy-assets-plugin.js |
Added withXcodeProject block to add folder reference to Xcode project on every prebuild |
example/src/App.tsx |
Added expo-file-system import + IOS_EDITOR_URI constant + editorUri={IOS_EDITOR_URI} prop on <CodeEditor> |
example/package.json |
Added expo-file-system: ~55.0.20 to dependencies |
After this fix, expo prebuild will:
- Copy assets to Android
assets/codeditor/✅ - Copy assets to iOS
CodeditorExample/assets/codeditor/✅ - Add the
assetsfolder reference toproject.pbxproj✅ (new — viawithXcodeProject)
No manual Xcode project editing needed on a fresh clone.
| File | Change |
|---|---|
example/ios/Podfile |
Added post_install block with SWIFT_STRICT_CONCURRENCY = minimal for all targets and SWIFT_VERSION = '5' for Expo Swift 6 targets |
example/ios/Gemfile |
Created — pins cocoapods ~> 1.16 |
patches/expo-modules-core+55.0.25.patch |
Created — removes invalid @MainActor from 3 conformance positions |
package.json |
Added "postinstall": "patch-package" to auto-apply patch after npm install |
example/copy-assets-plugin.js |
Added withXcodeProject to add assets folder reference to Xcode project during expo prebuild |
example/src/App.tsx |
Added IOS_EDITOR_URI using expo-file-system; passed as editorUri prop to <CodeEditor> |
example/package.json |
Added expo-file-system: ~55.0.20 to dependencies |
example/ios/CodeditorExample.xcodeproj/project.pbxproj |
Added assets folder reference to PBXFileReference + PBXResourcesBuildPhase |
| Fix | Status |
|---|---|
| Ruby 3.3 for pod install | ✅ Working |
SWIFT_STRICT_CONCURRENCY = minimal in Podfile |
✅ Applied |
SWIFT_VERSION = '5' for Expo Swift 6 targets |
✅ Applied |
patch-package for 3 invalid @MainActor conformance sites |
✅ Applied |
| iOS build (compilation) | ✅ Working |
| Assets folder in Xcode Copy Bundle Resources | ✅ Applied |
iOS URI via expo-file-system bundleDirectory |
✅ Applied |
| iOS editor loading | ✅ Working |