Taming Metro: How Bun's Symlinks Broke My React Native Build
How Bun's symlinks quietly broke my native modules - and the one config that fixed it.
TL;DR If you use Bun in a monorepo and your React Native app crashes with an error like "View config getter callback... must be a function" for things like
expo-camera
, the bundler is likely confused by symlinked native modules. Add the monoreponode_modules
to yourmetro.config.js
to fix it. Jump to the fix.
Of all the error messages a developer can face, the most maddening are the ones that send you searching for a problem you don't have. They are cryptic, un-Googlable, and feel like a personal attack by the compiler. I recently spent a weekend wrestling with one such beast in a React Native project using a Bun monorepo.
It all started when ExpoCamera
began to fail at runtime with this spectacularly unhelpful message:
[React] { [Invariant Violation: View config getter callback for component ViewManagerAdapter_ExpoCamera_4805570799137208488 must be a function (received undefined).] }
This wasn't just limited to the camera. Other native modules, like ExpoAppleAuthentication
, threw the exact same type of error. The common thread was native components, which suggested the problem wasn't in my code but somewhere deeper, in the tangled guts of the build system.
The Wild Goose Chase
I started with the usual ritual. I deleted node_modules
, ios
, and android
directories. I cleared every cache I could find - npm
, .expo
, you name it. I ran prebuilds with --clean
and builds with --no-build-cache
. I looked at npx expo-doctor
output, not finding anything that would point at issues. Nothing worked. The error persisted, smug and unshakable.
Google was no help either. The search results latched onto the generic Invariant Violation:
part of the error, leading me down a rabbit hole of posts about people incorrectly using <div>
tags in React Native. This was a classic red herring; I was using standard, Expo-approved components. To make matters worse, the stack-trace carets in the error screen were completely out of whack, pointing to unrelated lines and sending me on a futile quest to debug my authentication logic. The real clue was the component name itself: ViewManagerAdapter_ExpoCamera_...
. That looked suspiciously like a native linking error in disguise.
The First Clue
Just as I was about to give up, I ran npx expo-doctor
. One more time, not really expecting much. It passed most of its checks but failed one, like last time:
✖ Check that no duplicate dependencies are installed
Your project contains duplicate native module dependencies, which should be de-duplicated.
Native builds may only contain one version of any given native module, and having multiple versions of a single Native module installed may lead to unexpected build errors.
Found duplicates for @expo/metro-runtime:
├─ @expo/metro-runtime@6.1.2 (at: node_modules/@expo/metro-runtime)
└─ @expo/metro-runtime@6.1.2 (at: ../../node_modules/.bun/expo-router@6.0.10+7fd564c4bef372e8/node_modules/@expo/metro-runtime)
Found duplicates for @expo/vector-icons:
├─ @expo/vector-icons@15.0.2 (at: node_modules/@expo/vector-icons)
└─ @expo/vector-icons@15.0.2 (at: ../../node_modules/.bun/expo@54.0.12+20c3d978382e97fb/node_modules/@expo/vector-icons)
... (5 more pkgs omitted)
Advice:
Resolve your dependency issues and deduplicate your dependencies. Learn more: https://expo.fyi/resolving-dependency-issues
I dismissed it, again. "Yeah," I thought, "that's just how Bun's file linking works in a monorepo." Bun is incredibly fast precisely because it uses symbolic links to a central store instead of copying files into a local node_modules
directory. Seeing duplicate paths seemed like a normal, expected artifact of that process.
I was wrong.
The Root of the Problem: Finicky Linkers
React Native, and by extension Expo, is notoriously finicky about how its packages are laid out. The Metro bundler expects to find a single, coherent node_modules
tree to resolve native dependencies. Metro expects a single node_modules
tree so it can map each native view manager to exactly one binary. When it initializes a native module like ExpoCamera
, it needs to find the corresponding native code and its configuration - the "view config getter callback."
Bun's high-performance linking strategy, while brilliant for most web projects, shatters this expectation. By creating symlinks to modules located outside the project's immediate node_modules
directory (in the monorepo root), it presented Metro with a structure it couldn't parse. From Metro's perspective, there were two sources for the same native module, and it couldn't figure out which one was the "real" one. The result? It found undefined
instead of the required function, and the app crashed. The "duplicate dependencies" warning from expo-doctor
was pointing to the exact problem.
Which I ignored.
The Fix
Once I understood the root cause, the fix was surprisingly simple. I just had to explicitly tell Metro where to find all the packages. This involved a small change to the metro.config.js
file, adding paths to both the local and the monorepo's root node_modules
directories.
// metro.config.js
const { getDefaultConfig } = require("expo/metro-config");
const path = require("path");
const projectRoot = __dirname;
const workspaceRoot = path.resolve(projectRoot, "../..");
const config = getDefaultConfig(projectRoot);
// 1. Watch all files in the monorepo
config.watchFolders = [workspaceRoot];
// 2. Let Metro know where to resolve packages and in what order
config.resolver.nodeModulesPaths = [
path.resolve(projectRoot, "node_modules"),
path.resolve(workspaceRoot, "node_modules"),
];
module.exports = config;
With the config updated, I performed the cleansing ritual one last time:
# Nuke everything
find . -name "node_modules" -type d -prune -exec rm -rf {} +
cd apps/native && rm -rf ios android .expo
# Reinstall and rebuild
bun i
bunx expo prebuild
bunx expo run:ios
And just like that, it just worked. The cryptic error was gone.
The key takeaway is that the tools we use, especially in a complex ecosystem like React Native, have deep-seated assumptions. Bun's assumption is that symlinks are a faster way to manage dependencies. Metro's assumption is that all native modules live in one predictable place. When those assumptions clash, you end up on a weekend-long debugging adventure. The deepest bugs aren't always in our code, but in the invisible seams between our tools.
I want my day back.