My iOS app started crashing the moment it launched. No JS error, no red screen, just a native abort before anything rendered. TestFlight, local device builds, simulator, all the same.
The crash log:
dyld: Symbol not found: ExpoModulesCore.AnyModule._exposedDefinition()
Referenced from: ExpoFont.framework (and ExpoLocation.framework)
Expected in: ExpoModulesCore.framework
App terminated due to signal 6
If you're on Expo SDK 56 with React Native 0.85 and seeing a missing Expo symbol at launch, this writeup is probably relevant to you.
A missing symbol at launch reads like a stale binary, so I did the usual things first. expo prebuild --clean, deleted ios/ and DerivedData, cleared the CocoaPods cache. None of it changed anything.
The detail that bugged me: the prebuilt ExpoModulesCore just got re-downloaded each time, identical. So the cache wasn't stale. The binary was fine, it just didn't have the symbol the other modules were calling. That's a different problem.
Since SDK 56, Expo ships ExpoModulesCore on iOS as a prebuilt XCFramework instead of compiling it from source. It saves a lot of build time. The catch is that leaf modules like expo-font and expo-location still compile from source and link against that prebuilt core.
A prebuilt binary has a fixed ABI. If the source-compiled modules expect a slightly different core than the XCFramework was built against, you get this exact failure: the source side calls AnyModule._exposedDefinition(), the prebuilt side never exported it, dyld gives up.
So the real question was why my source modules and the prebuilt core disagreed about the ABI.
Expo lists the versions it expects per SDK in bundledNativeModules.json. SDK 56 says react-native-worklets 0.8.3 and react-native-reanimated 4.3.1. My app had worklets 0.9.1 and (transitively) reanimated 4.4.1, so I figured the version drift was the mismatch and pinned both back down.
The build broke harder:
HermesProfiling.cpp:40: no member named 'enableSamplingProfiler'
in 'facebook::hermes::HermesRuntime'
worklets 0.8.3 doesn't compile against RN 0.85 at all. RN 0.85 moved the Hermes profiling API onto IHermesRootAPI, and worklets 0.8.x still calls it as a static method on HermesRuntime. worklets 0.9.1 compiles fine. So my original version wasn't the problem, and the numbers in bundledNativeModules.json are the floor Expo tested, not something that holds for every RN patch. I put 0.9.1 back.
Once I stopped guessing and looked at what actually resolved, the bug was right there:
react-native-worklets@0.9.1 (the app)
expo-modules-core/react-native-worklets@0.8.3 (nested)
react-native-reanimated/react-native-worklets@0.8.3 (nested)
Three copies of worklets in one binary. Each got there for a different reason.
The app resolved 0.9.1 at the top level, which is right for RN 0.85. expo-modules-core@56.0.17 declares its worklets peer as ^0.7.4 || ^0.8.0 and refuses 0.9.x, so the package manager gave it a private nested 0.8.3. reanimated 4.3.1 wants 0.8.x, so it pulled its own nested 0.8.3.
The prebuilt ExpoModulesCore was compiled against the 0.8.x worklets ABI. The app and source modules linked against 0.9.1. Two ABIs in one process, and _exposedDefinition doesn't line up. The missing symbol was a symptom. The disease was multiple incompatible worklets binaries in the same address space.
Three changes, one per copy of worklets.
Pin reanimated to a version that wants 0.9.x. 4.4.1 has peer react-native-worklets: 0.9.x, and it's what most RN 0.85 projects ship anyway. That removes reanimated's nested 0.8.3.
// frontend/mobile/package.json
"react-native-reanimated": "4.4.1",
"react-native-worklets": "0.9.1",Override the expo-modules-core peer. 56.0.17 is the latest SDK 56 release and its peer range still rejects 0.9.x, which is just out of date relative to RN 0.85. Force one version at the workspace root:
// root package.json
"overrides": {
"react-native-worklets": "0.9.1"
}After this the lockfile has a single react-native-worklets@0.9.1 and the nested copies are gone.
Build only ExpoModulesCore from source. The prebuilt XCFramework is still baked against the 0.8 ABI, so even with one JS-side worklets the prebuilt core won't match. Instead of disabling precompilation for everything with EXPO_USE_PRECOMPILED_MODULES=0, opt out the one module:
// frontend/mobile/package.json
"expo": {
"autolinking": {
"ios": {
"buildFromSource": ["expo-modules-core"]
}
}
}Now ExpoModulesCore compiles from source against the same worklets as the rest of the app, the symbols line up, and every other Expo module stays prebuilt so the build stays fast.
That's the workaround you find first and it does work, because forcing everything from source leaves you with one ABI. But you lose the whole point of the prebuilt frameworks, and it hides the real issue. The tree still has three worklets in it. Building only expo-modules-core from source, plus fixing the graph so there's one worklets to begin with, gets the same result without recompiling everything.
A missing-symbol crash at launch is usually an ABI mismatch, not a stale cache. If clean builds and deleting DerivedData don't help, stop clearing caches.
bundledNativeModules.json is what Expo tested, not a rule. It can lag a React Native patch, so don't downgrade onto a version that won't compile against your RN.
The smoking gun was in the lockfile, not in package.json. Three copies of one native library never show up in your direct dependencies, only in what actually resolved.
The underlying rule is simple: a native module has to resolve to one version across the whole binary. Get there, make the prebuilt core agree, and the crash goes away.