Repository navigation
enableSchedulerDelegateInvalidation defaults to false, leaving Scheduler's deferred delegate callbacks exposed to use-after-free (EXC_BAD_ACCESS on the JS thread during teardown) #58601
Description
Description
Scheduler::uiManagerDidDispatchCommand and Scheduler::uiManagerDidFinishTransaction copy the raw pointer delegate_ into a lambda that is deferred through runtimeScheduler_->scheduleRenderingUpdate(...). If the delegate is destroyed before that lambda is drained, the lambda dereferences freed memory.
On 0.87.1 the guard for this exists, but it is gated behind a feature flag that is off by default, so a default build is still exposed:
// Scheduler.cpp (v0.87.1)
auto guardEnabled =
ReactNativeFeatureFlags::enableSchedulerDelegateInvalidation();
runtimeScheduler_->scheduleRenderingUpdate(
shadowNode->getSurfaceId(),
[delegate = delegate_,
invalidated = delegateInvalidated_,
guardEnabled, /* ... */]() {
if (guardEnabled && *invalidated) {
return;
}
delegate->schedulerDidDispatchCommand(shadowView, commandName, args);
});// ReactNativeFeatureFlagsDefaults.h (v0.87.1)
bool enableSchedulerDelegateInvalidation() override {
return false;
}With the flag off, neither setDelegate() nor ~Scheduler() neutralises lambdas that already captured the old pointer, so on iOS RCTScheduler's dealloc — which calls setDelegate(nullptr) and then frees the delegate proxy — leaves the RuntimeScheduler free to drain a stale lambda on the JS thread.
What I am asking: is there a reason the guard is still opt-in, and is there a plan to enable it by default? If the guard is considered correct, a default of false means every released app is still exposed to this.
Three further observations from reading the sources:
maincurrently has no guard at all inuiManagerDidDispatchCommand— theguardEnabled/invalidatedcapture present in v0.86.3 and v0.87.1 is absent there. If that is not intentional, the fix may have been lost on main.- On 0.81.x the flag does not exist at all (neither in
ReactNativeFeatureFlags.hnor in the JS feature-flag API), so apps on that line have no mitigation available whatsoever. - The same raw-pointer capture pattern is used in
uiManagerDidFinishTransaction, which runs on every non-synchronous transaction, not just on commands.
Note on Expo: the reporting app uses Expo, but this is not an Expo defect — the faulting frame is React Native's own renderer C++ (Scheduler.cpp), which Expo does not patch. Filing here rather than on the Expo tracker for that reason.
Note on the version field: the production crash below was captured on 0.81.5, where no guard exists. I have filed this against 0.87.1 because I verified by reading v0.87.1's sources that the unguarded path is still the default there. I have not run 0.87.1 myself.
Steps to reproduce
This is a race with a window of a few milliseconds, so the steps below describe the production scenario rather than a deterministic recipe. Reconstructed from the breadcrumbs of the crashing session:
- Open a screen with a
ScrollViewcontaining several chainedTextInputs, whereonSubmitEditingcallsnextRef.current?.focus(). Eachfocus()goes throughUIManager.dispatchCommand, and therefore throughScheduler::uiManagerDidDispatchCommand. - Type, moving focus between fields so the keyboard shows and hides repeatedly.
- Background the app immediately after a focus change. (In the observed session the user left the app to fetch an emailed sign-in code.)
- The app crashes on the JS thread while the main thread is still inside the
UIApplicationDidEnterBackgroundobserver.
The simultaneity is visible in the report: the main thread is inside __CFNOTIFICATIONCENTER_IS_CALLING_OUT_TO_AN_OBSERVER__ at the moment the JS thread faults.
React Native Version
0.87.1
Affected Platforms
Runtime - iOS
Output of npx @react-native-community/cli info
System:
OS: macOS 26.6.2
CPU: (14) arm64 Apple M3 Max
Memory: 177.64 MB / 36.00 GB
Shell:
version: "5.9"
path: /bin/zsh
Binaries:
Node:
version: 22.14.0
path: /Users/garri/.nvm/versions/node/v22.14.0/bin/node
Yarn:
version: 1.22.22
path: /Users/garri/.nvm/versions/node/v22.14.0/bin/yarn
npm:
version: 11.4.2
path: /Users/garri/.nvm/versions/node/v22.14.0/bin/npm
Watchman:
version: 2026.07.27.00
path: /opt/homebrew/bin/watchman
Managers:
CocoaPods:
version: 1.17.0
path: /opt/homebrew/bin/pod
SDKs:
iOS SDK:
Platforms:
- DriverKit 25.5
- iOS 26.5
- macOS 26.5
- tvOS 26.5
- visionOS 26.5
- watchOS 26.5
Android SDK: Not Found
IDEs:
Android Studio: 2025.3 AI-253.29346.138.2531.14850935
Xcode:
version: 26.5/17F42
path: /usr/bin/xcodebuild
Languages:
Java:
version: 17.0.18
path: /usr/bin/javac
Ruby:
version: 4.0.6
path: /opt/homebrew/bin/ruby
npmPackages:
"@react-native-community/cli": Not Found
react: Not Found
react-native: Not Found
react-native-macos: Not Found
npmGlobalPackages:
"*react-native*": Not Found
Android:
hermesEnabled: Not found
newArchEnabled: Not found
iOS:
hermesEnabled: Not found
newArchEnabled: Not found
Additional context: Expo SDK 54, Hermes, New Architecture enabled (`newArchEnabled: true`),
`buildReactNativeFromSource: true` on iOS. Crash captured on react-native 0.81.5.
Stacktrace or Logs
EXC_BAD_ACCESS
KERN_INVALID_ADDRESS at 0x344e880dcd072e6e
Crashed thread: com.facebook.react.runtime.JavaScript
facebook::react::Scheduler::uiManagerDidDispatchCommand::lambda::operator() Scheduler.cpp:286
std::__1::__invoke[abi:ne200100]<T> invoke.h:179
std::__1::__invoke_void_return_wrapper<T>::__call[abi:ne200100]<T> invoke.h:251
std::__1::__invoke_r[abi:ne200100]<T> invoke.h:273
std::__1::__function::__alloc_func<T>::operator()[abi:ne200100] function.h:167
std::__1::__function::__func<T>::operator() function.h:319
std::__1::__function::__value_func<T>::operator()[abi:ne200100] function.h:436
std::__1::function<T>::operator() function.h:995
facebook::react::RCTMessageThread::runAsync RCTMessageThread.mm:44
__CFRUNLOOP_IS_CALLING_OUT_TO_A_BLOCK__ CoreFoundation
__CFRunLoopDoBlocks CoreFoundation
__CFRunLoopRun CoreFoundation
_CFRunLoopRunSpecificWithOptions CoreFoundation
+[RCTJSThreadManager runRunLoop] RCTJSThreadManager.mm:102
__NSThread__start__ Foundation
_pthread_start libsystem_pthread.dylib
Main thread at the same moment, processing the background transition:
__CFNOTIFICATIONCENTER_IS_CALLING_OUT_TO_AN_OBSERVER__ CoreFoundation
___CFXRegistrationPost_block_invoke CoreFoundation
_CFXRegistrationPost CoreFoundation
_CFXNotificationPost CoreFoundation
__CFRunLoopRun CoreFoundation
Notes:
- Line 286 on 0.81.5 is exactly `delegate->schedulerDidDispatchCommand(shadowView, commandName, args);`
inside the deferred lambda.
- The faulting address is arbitrary garbage rather than a small offset from null, which is consistent
with freed-and-reused memory rather than a null delegate.
- Device: iPhone 15 Pro Max (iPhone16,2), iOS 27.0 (24A437, RELEASE kernel), App Store build,
crash ~48 s after cold start, while the app was moving to the background.
MANDATORY Reproducer
I do not have a standalone reproducer. This is a production crash; the window between queueing the lambda and the delegate being freed is a few milliseconds and I have not been able to force it on demand. I am filing anyway because the defect is verifiable statically rather than empirically: the deferred lambda captures a raw SchedulerDelegate*, the invalidation guard that would neutralise it is compiled in but disabled by default, and the symbolicated crash lands on exactly that dereference. If a running reproducer is a hard requirement, I would appreciate a pointer to the existing tracking issue for enableSchedulerDelegateInvalidation so this report can be attached there instead.
Screenshots and Videos
N/A
Activity
react-native-bot commentedon Sep 19, 2026
Warning
Missing reproducer: We could not detect a reproducible example in your issue report. Reproducers are mandatory and we can accept only one of those as a valid reproducer:
You can read more about about it on our website: How to report a bug.
olebird commentedon Sep 24, 2026
We're hitting this in production and can add data on the trigger.
Environment: Expo SDK 57 (57.0.24), React Native 0.86.2 and 0.86.3 (prebuilt
React.framework), Hermes V1, New Architecture (bridgeless), iOS 17–27.Symbolicated crash. Symbolicated with
react-native-artifacts-0.86.2-reactnative-core-dSYM-release(React UUID9C5C8520-5E9F-39C9-AF92-C54F4C95004F):Builds on 0.86.3 crash at the same frame.
The delegate looks freed. In all 3 events we inspected,
x9equals the fault address minus 8. That is a load of vtable slot 1 (schedulerShouldRenderTransactions) through a garbage vptr. The fault addresses were0x44,0x63e6337b30ba4a4cand0x1e3e5451a2b37aa7.Trigger: a reload with no error. Our app runs
Updates.checkForUpdateAsync()→fetchUpdateAsync()→Updates.reloadAsync()(expo-updates) about 1–2 s after launch.Downloading updateandUpdate downloaded.Restarting for updatejust before the crash.Scale: about 600 crash events from about 550 users since May 2026, about 14 per day over the last 30 days. It is typically one crash per user: the crash happens on the reload, and the next launch runs the new bundle.
Teardown path as we read the 0.86 sources:
reloadAsync→RCTInstanceinvalidate →[RCTSurfacePresenter suspend]releases theRCTScheduler.-[RCTScheduler dealloc]calls_scheduler->setDelegate(nullptr), and then the ivar destruction frees_delegateProxy.enableSchedulerDelegateInvalidationoff, a lambda already queued throughscheduleRenderingUpdatekeeps its copy of the raw pointer. The JS thread can still drain that lambda afterwards.JoaoPauloCMarra commentedon Sep 30, 2026
One Android delivery detail: changing only ReactNativeFeatureFlagsDefaults.h does not enable the guard when the Java provider still returns false. In a source-built RN 0.86.3 app, enabling the Java default as well produced a compiled APK getter returning true; without source compilation a package-local Kotlin edit would not reach the Maven AAR.
I am retaining this as a release backport, not proposing a main-branch default flip after the flag removal. Is there a planned runtime-scheduler shutdown/invalidation contract for normal delegate teardown without an uncaught error? Queue clearing in handleTaskError covers a different lifetime boundary.
docs(maestro): note the reload-crash repro and upstream status on smo…
harrisonokojiee commentedon Oct 14, 2026
We hit the same frame on 0.86.3 and found a development trigger that reproduces it within about a second, 3 times in 4 from a busy screen. It needs
expo-dev-client, so it isn't an RNTester reproducer, but the teardown it exercises is React Native's own.Environment: Expo SDK 57 (
expo57.0.28,expo-dev-client57.0.20,expo-router57.0.26),react-native0.86.3 with the prebuiltReact.framework(Debug), Hermes, bridgeless, default Stable release level (soenableSchedulerDelegateInvalidationis off),expo-build-propertiesios.enableSceneSupport: true. Built with Xcode 27.0 (27A266a), running on an iOS 26.5 Simulator.Trigger: switching a running development client to another Metro server. That's a normal teardown started by the dev launcher, not by a JS error, so it lands in the "delegate dropped and destroyed with no error involved" window that #58138 says is still open.
--initialUrlor the same link)We have six reports (two from daily use, four from these runs). All are
EXC_BAD_ACCESSoncom.facebook.react.runtime.JavaScript, about a second after the link where we timed it. There are always two JS threads, and the older one (the instance being torn down) is the one that crashes. In four reports the virtual call through the captured delegate jumps into the heap (3×SIGBUS) or to 0 (1×SIGSEGV); in the other two, the vtable-slot load just before it faults on a garbage vptr (SIGSEGV).Stack and disassembly
$_0::operator()in the prebuilt 0.86.3React.framework(UUID581BE8B5-F252-3F2D-8931-FA5D1375BA22):Teardown order, as we read the 0.86.3 and expo-dev-launcher 57 sources: it's the same path olebird's comment above describes for
reloadAsync. The launcher drops the oldRCTHoston the main thread and builds a new one.-[RCTHost dealloc]→-[RCTInstance invalidate]→-[RCTSurfacePresenter suspend]releases theRCTScheduler. ItsdealloccallssetDelegate(nullptr)and then frees_delegateProxy. The JS-thread cleanup ininvalidateis only dispatched, so the old JS thread finishes its current event-loop tick and drains a rendering update that still holds the raw delegate pointer.Why the switch hits it so often: with Expo's scene support, the dev-client link is also handed to
RCTLinkingManager, so the old runtime gets aurlevent. expo-router resolves it to/and starts navigating, which means React commits on the old JS thread just as the host goes away. We confirmed this by pointing the link at a server that accepts the connection but never answers. That freezes the switch before teardown, and the still-running old instance navigated to/(same process). That part is Expo-specific, but it suggests any work that commits duringinvalidatecan trigger this. A pure-RN repro might be a screen that commits continuously whileDevSettings.reload()runs, which looks like #44755.One note for release planning: 0.86.3 and 0.87.1 still let apps opt in, but
v0.88.0-rc.5has neither the guard nor the flag. Is there an issue tracking the runtime-scheduler-level shutdown signal that #58138 mentions? Until then we avoid live switching (terminate first) in development; our app never tears down a host in release builds.