I recently sat down to build the iOS and Android dev clients for an Expo app that lives inside a Bun workspaces monorepo (Expo SDK 56, React Native new architecture, everything managed with bun). Android built on the first try. iOS blew up in the middle of compiling a native module. And once I got iOS building, the JavaScript bundle refused to load on both platforms.
Three different-looking failures. One root cause. This is the write-up of how I chased it down, because the root cause is something anyone running Expo on Bun’s newer install mode will eventually hit.
The three symptoms
1. iOS wouldn’t compile a Swift macro
The iOS build died with xcodebuild exit code 65 and this in the logs:
❌ (…/node_modules/…/expo-crypto/ios/CryptoModule.swift:22:16)
21 | @OptimizedFunction
> 22 | private func randomUUID() -> String {
| ^ external macro implementation type
'ExpoModulesMacros.OptimizedFunctionAttachedMacro' could not be found
for macro 'OptimizedFunction'; No such file or directory
Expo uses Swift macros in some native modules now. The macro implementation ships as a compiler plugin in a separate package (@expo/expo-modules-macros-plugin), and CocoaPods wires it into the Swift compiler via an absolute path in the generated xcconfig files:
OTHER_SWIFT_FLAGS = … -load-plugin-executable
"…/expo-modules-core/node_modules/@expo/expo-modules-macros-plugin/apple/ExpoModulesMacros-tool#ExpoModulesMacros"
I checked that exact path on disk. It didn’t exist. The macro tool was real, but it lived somewhere else entirely.
2. Metro couldn’t resolve @expo/metro-runtime
Once iOS compiled, starting Metro failed to bundle — identically on iOS and Android:
Unable to resolve "@expo/metro-runtime" from
"node_modules/…/expo-router/entry-classic.js"
@expo/metro-runtime is a dependency of expo-router. It was installed. It just wasn’t resolvable from the app.
3. expo-doctor reported a “corrupted” node_modules
Your node_modules folder may be corrupted. Multiple copies of the same
version exist for: expo, expo-modules-core, react-native, expo-font, …
Same versions, multiple physical copies. That was the tell.
The common thread
All three point at how packages are laid out on disk.
Bun’s newer isolated linker (the one that produces a node_modules/.bun/ content-addressed store, pnpm-style) installs every package into its own directory keyed by a hash of its resolved peer-dependency set, and symlinks dependencies as siblings. It’s a good design for strictness and dedup in theory. The problem is that Expo’s entire native toolchain assumes the old hoisted, npm-style flat node_modules.
That assumption is baked in three places, which is exactly why I got three failures:
- CocoaPods autolinking computes the macro-plugin path assuming the plugin is nested under
expo-modules-core/node_modules/@expo/…. Under the isolated store the plugin is hoisted into the content-addressed store instead, so the baked path points at a directory that never exists. - Metro in this project runs with
resolver.disableHierarchicalLookup = true, so it only checks a fixed list ofnodeModulesPaths. Transitive deps like@expo/metro-runtimeonly exist as siblings deep inside the.bunstore, which isn’t on that list. - The isolated store itself creates one physical copy of a package per unique peer set. Expo’s duplicate check sees N copies of the same version and calls it corrupted.
So the real question wasn’t “how do I patch each of these” — it was “how do I give Expo the flat layout it expects.”
The fix: one linker setting
Bun lets you choose the linker. I added a bunfig.toml at the repo root:
[install]
linker = "hoisted"
Then nuked node_modules and the lockfile and reinstalled from clean. The results were immediate and satisfying:
node_modules/became a flat tree again — no.bun/store.- Package count dropped by nearly half (the duplicates collapsed into single copies).
pod installnow baked the correct, existing macro-plugin path — iOS compiled with zero changes to any generated file.expo-doctor’s “duplicate installations” and “corrupted node_modules” errors vanished.
One line fixed the macro plugin at the source (no post-install patch script, no editing generated files that get regenerated anyway) and deduped the tree.
Two supporting fixes
The linker switch did most of the work, but two smaller things needed attention to get JavaScript bundling fully green.
Declare @expo/metro-runtime directly
Even hoisted, the cleanest fix for the Metro resolution error is to make the runtime a first-class dependency of the app rather than relying on it being pulled in transitively:
npx expo install @expo/metro-runtime
This is also the officially recommended setup for expo-router. With it declared, the package hoists next to the app and Metro resolves it via nodeModulesPaths.
Drop disableHierarchicalLookup
With @expo/metro-runtime fixed, the bundle got much further — 1,800+ modules in — before hitting the next wall:
Unable to resolve module semver/functions/satisfies from
node_modules/react-native-reanimated/scripts/validate-worklets-version.js
Reanimated does a runtime version check that requires a deep subpath of semver. It needs semver@7, but a different major of semver was hoisted to the root, so Reanimated kept its own copy nested beside itself. With disableHierarchicalLookup = true, Metro refuses to walk up the tree to find that nested copy — it only looks in the two configured paths.
The old monorepo Metro configs used to include that flag, but current Expo guidance (and expo-doctor) flag it as an override to remove. So I removed it:
// metro.config.js
config.watchFolders = [workspaceRoot]
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, "node_modules"),
path.resolve(workspaceRoot, "node_modules"),
]
// (removed) config.resolver.disableHierarchicalLookup = true
With hierarchical lookup back on, Metro walks up from the Reanimated script, finds the nested semver@7, and the bundle completes. expo export produced a clean bundle on both platforms.
Since I was in there: dependency alignment
expo-doctor had also flagged a pile of version drift. For an Expo project the right tool isn’t “bump everything to latest” — it’s expo install --fix, which pins each Expo-managed package to the version the installed SDK actually expects:
npx expo install --fix
That aligned React, Reanimated, gesture-handler, safe-area-context and friends to their SDK-pinned versions, while I bumped the genuinely independent, non-Expo packages to their latest non-major releases (skipping the big migrations like the next SDK, RN major, and a couple of library majors — those deserve their own PR). A couple of small notes from that pass:
- If you want to keep a package that Expo would otherwise “correct,”
expo.install.excludeinpackage.jsontellsexpo installto leave it alone. - Keep
react-test-rendererpinned to the exact same version asreact, or the test renderer complains.
The scoreboard, before and after:
| Check | Before | After |
|---|---|---|
expo-doctor passing | 15/21 | 20/21 |
| iOS dev build | ❌ | ✅ |
| Android dev build | ❌* | ✅ |
| Metro bundle (both) | ❌ | ✅ |
*Android’s native build actually succeeded from the start; the one failure I saw there was a flaky adb install (cmd: Can't find service: package — the emulator’s package service wasn’t ready yet), which cleared on a retry. Worth knowing so you don’t go chasing a “build failure” that isn’t one.
The single remaining expo-doctor item is a genuine architectural to-do unrelated to the build (a library the SDK now recommends migrating away from), so I left it for a dedicated change.
Takeaways
- If you run Expo on Bun, use the hoisted linker. Expo’s native tooling — CocoaPods path baking, Metro resolution, its duplicate checks — assumes a flat
node_modules. The isolated store is a great default for a lot of projects, but it quietly breaks all three.[install] linker = "hoisted"inbunfig.toml. - When several unrelated errors show up at once, look for a shared cause. A macro plugin, a Metro resolver, and a “corrupted node_modules” warning sound like three tickets. They were one.
- Fix at the source, not the symptom. Patching generated
xcconfigfiles would have “worked” until the nextpod installwiped them. Changing the layout that produces them is durable. - Let the framework pick versions.
expo install --fixbeats hand-bumping for anything the SDK manages, and reserve the majors for their own PR. - Trust the doctor, but read it twice.
expo-doctorpointed straight at thedisableHierarchicalLookupoverride and the version drift. It also flags things that are intentional — know which is which before you “fix” them.
One config line, two small resolver tweaks, and a dependency alignment pass. Both platforms build, install, and launch. Shipping it.