- engineering blog
- Product
5 min read
Tracking down OTA-related crashes in our Mobile app
)
Tomasz Krzyżowski Senior Product Engineer
We recently launched a complete overhaul of our iOS app, taking full advantage of the Liquid Glass design paradigm launched by Apple in iOS26. After releasing the app, our telemetry showed that a small but significant number of users were experiencing more crashes than previously. Debugging this sent us down a rabbit hole of technology, including Expo, react-native-unistyles, and React Native.
First, credit to the Expo team for providing a great tool expo-updates that makes our lives much easier. Thanks to that, hot-fixes and other urgent changes can be delivered to our users in a matter of minutes! However, there is a hidden issue with the OTA mechanism that I’m going to thoroughly explain down below.
What are OTA updates?
In essence, OTA stands for over the air, which means that your device can download updates without installing anything new. You may ask: how is that possible without a review from the Apple / Google Store etc.? The answer is pretty simple and it’s directly connected to the way React Native apps work under the hood. These updates are essentially a new JavaScript bundle that is fetched and applied by re-running it using the JS engine like Hermes, or JSC on the native side. That’s why you shouldn’t introduce changes related to native library dependencies (bumping versions, adding new ones) within the code that you’re going to serve by OTA. Check out this awesome blog post to learn more.
How does it all begin? Crash report obviously!
Firstly, let me tell you that our tech-stack is quite a standard one:
expo-dev-clientreact-navigationreact-native-unistyles
After releasing our redesigned app, we started receiving crash reports with this message:
Unistyles was loaded, but it's not configured. Did you forget to call StyleSheet.configure? If you don't want to use any themes or breakpoints, simply call it with an empty object {}.
What was surprising was that the crash's stack traces appeared random because they were coming from several different places in our code.
We started digging into the C++ implementation of react-native-unistyles and found that this assertion was failing.
// UnistylesRegistry.cpp
// ...
core::UnistylesState& core::UnistylesRegistry::getState(jsi::Runtime& rt) {
auto it = this->_states.find(&rt);
helpers::assertThat(rt, it != this->_states.end(), "Unistyles was loaded, but it's not configured. Did you forget to call StyleSheet.configure? If you don't want to use any themes or breakpoints, simply call it with an empty object {}.");
return it->second;
}
// ...We were sure that we were calling StyleSheet.configure as early as possible in our bundle, but we knew from this that we needed to find a place where the new entry was being added to the this->_states map.
There it is → UnistylesRegistry::createState
// UnistylesRegistry.cpp
// ...
void core::UnistylesRegistry::createState(jsi::Runtime& rt) {
auto it = this->_states.find(&rt);
this->_states.emplace(
std::piecewise_construct,
std::forward_as_tuple(&rt),
std::forward_as_tuple(rt)
);
}
// ...It’s being called here by HybridStyleSheet::init method:
// HybridStyleSheet.cpp
// ...
jsi::Value HybridStyleSheet::init(jsi::Runtime &rt, const jsi::Value &thisVal, const jsi::Value *arguments, size_t count) {
if (this->isInitialized) {
return jsi::Value::undefined();
}
// create new state
auto& registry = core::UnistylesRegistry::get();
registry.createState(rt);
loadExternalMethods(thisVal, rt);
this->isInitialized = true;
return jsi::Value::undefined();
}
// ...So it looked like either this->isInitialized was set to true and the code returned early or … something else related to the HybridStyleSheet initialization was happening. Since the crashes were triggered in a few different places, we started to think that they might’ve been caused by some race conditions during the initialization.
The app listens for background-to-foreground transitions and checks whether any OTA updates are ready to be fetched and applied. Thanks to that we were able to identify that it had to be somehow connected with the update process. Indeed, reloadAsync() turned out to be a useful lead that directed us to the RCTTriggerReloadCommandListeners here in the react-native repo.
// RCTReloadCommand.m
// ...
void RCTTriggerReloadCommandListeners(NSString *reason)
{
[listenersLock lock];
[[NSNotificationCenter defaultCenter] postNotificationName:RCTTriggerReloadCommandNotification
object:nil
userInfo:@{
RCTTriggerReloadCommandReasonKey : RCTNullIfNil(reason),
RCTTriggerReloadCommandBundleURLKey : RCTNullIfNil(bundleURL)
}];
for (id<RCTReloadListener> l in [listeners allObjects]) {
[l didReceiveReloadCommand];
}
[listenersLock unlock];
}
// ...... and RCTHost will attach itself as a listener here
// RCTHost.mm
// ...
RCTExecuteOnMainQueue(^{
// Listen to reload commands
RCTRegisterReloadCommandListener(self);
});
// ...... so when reloadAsync is called, RCTInstance starts its invalidation process, which is asynchronous. That's the key to the whole puzzle. RCTHost calls invalidate here.
// RCTHost.mm
// ...
- (void)_reloadWithShouldRestartSurfaces:(BOOL)shouldRestartSurfaces
{
[_instance invalidate];
// ...
}
// ...We also knew that the Expo team recommends not to schedule any JS work after calling reloadAsync, and that was crucial information for us and a good new clue for our investigation.
We started experimenting with the reload mechanism and tried to reload the app in the dev mode while simulating heavy load on JS thread and … bingo! That was it.
The experiment
Chart walk-through:
Main thread starts the react native app:
Calls
[RCTHost start]that calls[RCTInstance _start]and in the end thisRCTInstancestarts new JS thread (every time_startis called!)
JS thread is busy doing its job (some heavy computational task i.e)
In the meantime the reload is requested (could be from expo, or CMD+R (Refresh command) doesn’t matter).
The command is received on the
Main threadand listeners (i.e RCTHost) are being notified anddidReceiveReloadCommandmethod is calledThis trigger invalidate method
dispatchOnJSThreadwith[turboModulesManager invalidate]baked into the callbackJS thread is still busy … but at the same time a new start procedure is starting and a new
JS threadwill be created (second one)JS thread finally executes its callback (b) that calls
[turboModulesManager invalidate]This call will be processed asynchronously on the
Turbo modules queuebecauseUnistylesdoesn’t declare explicitly its queueThis shows us 2 JS threads at the same time. This is the place where the newly created thread is
stuck
Turbo modules are being invalidated during the
evaluateJavaScriptJS bundle tries to call just invalidated module and we get a crash
Conclusion
The root cause was ordering. HybridStyleSheet::init ran for the new runtime before teardown from the previous runtime had fully completed. Later, delayed invalidation cleared the registry state, leaving UnistylesRegistry with an empty this->_states map and causing crashes when the new bundle tried to access it.
The fact that the invalidation process is asynchronous might have a hidden impact on your app and could be a source of some mysterious crashes, so keep that in mind!
The creator of the react-native-unistyles fixed the issue here, and it should be released in 3.1.0.
Interested in working on problems like this?
Come build with us.