Skip to content
Back to blog
Jun 30, 2026
7 min read
-- views

When Bun's Isolated Linker Broke My Expo Native Builds

A dev log on why iOS and Android dev builds failed in a Bun monorepo running Expo, and how one config line — switching to the hoisted linker — fixed the Swift macro plugin, Metro resolution, and duplicate installs all at once.
Share

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 of nodeModulesPaths. Transitive deps like @expo/metro-runtime only exist as siblings deep inside the .bun store, 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 install now 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.exclude in package.json tells expo install to leave it alone.
  • Keep react-test-renderer pinned to the exact same version as react, or the test renderer complains.

The scoreboard, before and after:

CheckBeforeAfter
expo-doctor passing15/2120/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" in bunfig.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 xcconfig files would have “worked” until the next pod install wiped them. Changing the layout that produces them is durable.
  • Let the framework pick versions. expo install --fix beats hand-bumping for anything the SDK manages, and reserve the majors for their own PR.
  • Trust the doctor, but read it twice. expo-doctor pointed straight at the disableHierarchicalLookup override 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.