Repository navigation
[Pro 1.8.1 / iOS] setTheme deadlocks on cold start: JS thread dispatch_syncs the main queue while the main thread waits on JS #751
Description
What happened?
Uniwind.setTheme on iOS can deadlock the process during launch. The system then kills it with the scene-update watchdog (0x8badf00d).
This is a different path from #745. #745 is the prepareTransition snapshot: it blocks the JS thread, but returns once the main thread is free. This path never returns.
Call path (Pro 1.8.1): Uniwind.setTheme → HybridUniwindConfig::setTheme → NativeIOSPlatform.setTheme → onNativePlatformChange(source: .user) → getLatest(). getLatest() reads UIKit through onMainThread:
func onMainThread<T>(_ closure: @escaping () -> T) -> T {
if Thread.isMainThread {
return closure()
} else {
return DispatchQueue.main.sync(execute: closure)
}
}setTheme runs on the JS thread, so this is DispatchQueue.main.sync from the JS thread.
React Native's iOS event beat drains the JS queue from the main run loop with executeSynchronouslyOnSameThread_CAN_DEADLOCK: the main thread blocks until the JS runtime finishes the current work. When that work is a setTheme that syncs back to the main queue, each side waits for the other.
Measured on a release build, iPhone 17, iOS 27, Uniwind Pro 1.8.1. The launch screen stayed up for about two minutes, then the process disappeared. The same device had four more kills the same afternoon, plus the same signature on an earlier build that morning. During the hang the app's own CPU time was 0.014s, so it was blocked, not busy. These reports stayed on device (share_with_app_devs = 0), so they do not show up in App Store Connect.
The two stacks line up:
- Main thread: run-loop event callback →
executeSynchronouslyOnSameThread_CAN_DEADLOCK, waiting for the JS runtime. - JS thread:
Uniwind.setTheme→NativeIOSPlatform.setTheme→dispatch_synconto the main queue.
It only happens when the theme call lands inside a main-thread event beat, so it takes several cold launches to hit. The splash never hides because the root layout never commits.
prepareTransition adds a second onMainThread hop on the same call (the snapshot from #745). Either hop is enough to deadlock; both run on every non-animated setTheme that changes the effective theme.
Suggested fix: never DispatchQueue.main.sync from the JS thread.
- A theme switch only changes
currentThemeName,prefersAdaptiveThemes, and the color scheme. Build the runtime update from the cached screen, insets, and font scale, and notify the listener directly. Fall back toDispatchQueue.main.asyncwhen there is no cache yet. - Return immediately from
prepareTransitionfor.none(see [Pro 1.8.1 / iOS + Android] setTheme without a transition preset blocks the JS thread on the main thread to snapshot the window, then discards it #745) so the silent path takes no main-queue hop at all.
Steps to Reproduce
- Release build on iOS, Pro 1.8.1.
- Call
Uniwind.setTheme('dark')with no transition preset from JS while the app is still bootstrapping, for example from a module initializer or the first layout effect, before the first screen commits. - Relaunch until the call overlaps the main-thread event beat. The launch screen then stays up until the watchdog kills the process (about 2 minutes).
Snack or Repository Link (Optional)
No response
Uniwind version
1.8.1 (Pro)
React Native Version
0.88
Platforms
iOS
Expo
Yes (SDK 58)
Additional information
- I've searched for similar issues in this repository and found none
- I am a Uniwind Pro user
Related: #745 covers the wasted snapshot on the same call. Fixing only the snapshot still leaves onNativePlatformChange → getLatest() → DispatchQueue.main.sync, which deadlocks on its own.
Activity
github-actions commentedon Oct 28, 2026
New release is published: https://docs.uniwind.dev/pro/changelog