Skip to content

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

Open

Description

@kabonia

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:

  1. main currently has no guard at all in uiManagerDidDispatchCommand — the guardEnabled / invalidated capture 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.
  2. On 0.81.x the flag does not exist at all (neither in ReactNativeFeatureFlags.h nor in the JS feature-flag API), so apps on that line have no mitigation available whatsoever.
  3. 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:

  1. Open a screen with a ScrollView containing several chained TextInputs, where onSubmitEditing calls nextRef.current?.focus(). Each focus() goes through UIManager.dispatchCommand, and therefore through Scheduler::uiManagerDidDispatchCommand.
  2. Type, moving focus between fields so the keyboard shows and hides repeatedly.
  3. Background the app immediately after a focus change. (In the observed session the user left the app to fetch an emailed sign-in code.)
  4. The app crashes on the JS thread while the main thread is still inside the UIApplicationDidEnterBackground observer.

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

  1. react-native-bot commented on Sep 19, 2026

    @react-native-bot
    Collaborator

    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.

  2. olebird commented on Sep 24, 2026

    @olebird

    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 UUID 9C5C8520-5E9F-39C9-AF92-C54F4C95004F):

    EXC_BAD_ACCESS / KERN_INVALID_ADDRESS, thread com.facebook.react.runtime.JavaScript
    Scheduler::uiManagerDidFinishTransaction(...)::$_0::operator()   Scheduler.cpp:331
      -> delegate->schedulerShouldRenderTransactions(mountingCoordinator)
    std::__function::__func<...$_0...>::operator()                   function.h:313
    RuntimeScheduler_Modern::updateRendering(HighResTimeStamp)        RuntimeScheduler_Modern.cpp:357
    RuntimeScheduler_Modern::runEventLoopTick(...)                    RuntimeScheduler_Modern.cpp:322
    

    Builds on 0.86.3 crash at the same frame.

    The delegate looks freed. In all 3 events we inspected, x9 equals the fault address minus 8. That is a load of vtable slot 1 (schedulerShouldRenderTransactions) through a garbage vptr. The fault addresses were 0x44, 0x63e6337b30ba4a4c and 0x1e3e5451a2b37aa7.

    Trigger: a reload with no error. Our app runs Updates.checkForUpdateAsync() → fetchUpdateAsync() → Updates.reloadAsync() (expo-updates) about 1–2 s after launch.

    • In the 100 most recent crash events, 100/100 sessions had an OTA update downloaded in the same session, going by the expo-updates breadcrumbs Downloading update and Update downloaded.
    • 65/100 also logged Restarting for update just before the crash.
    • None of the 100 came from a session without an update download, even though every launch runs the update check.

    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:

    1. reloadAsync → RCTInstance invalidate → [RCTSurfacePresenter suspend] releases the RCTScheduler.
    2. -[RCTScheduler dealloc] calls _scheduler->setDelegate(nullptr), and then the ivar destruction frees _delegateProxy.
    3. With enableSchedulerDelegateInvalidation off, a lambda already queued through scheduleRenderingUpdate keeps its copy of the raw pointer. The JS thread can still drain that lambda afterwards.
  3. JoaoPauloCMarra commented on Sep 30, 2026

    @JoaoPauloCMarra

    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.

  4. added a commit that references this issue on Oct 7, 2026
    1cacbaa
  5. harrisonokojiee commented on Oct 14, 2026

    @harrisonokojiee

    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 (expo 57.0.28, expo-dev-client 57.0.20, expo-router 57.0.26), react-native 0.86.3 with the prebuilt React.framework (Debug), Hermes, bridgeless, default Stable release level (so enableSchedulerDelegateInvalidation is off), expo-build-properties ios.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.

    npx expo start --dev-client --port 8095
    xcrun simctl launch <udid> <bundle-id> --initialUrl http://127.0.0.1:8095
    # open a screen that does some rendering work, then switch the running client
    # (we alternated 127.0.0.1 and localhost so each link is a real switch):
    xcrun simctl openurl <udid> "exp+<slug>://expo-development-client/?url=http%3A%2F%2Flocalhost%3A8095"
    Switch (clean simulator, same build) Crashed
    Live switch with the link, all screens 4 / 9
    … from a dashboard screen (several cards, an animated ring) 3 / 4
    … from a light sign-up form 1 / 4
    Terminate first, then cold launch (--initialUrl or the same link) 0 / 10

    We have six reports (two from daily use, four from these runs). All are EXC_BAD_ACCESS on com.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
    Thread 5 Crashed:: com.facebook.react.runtime.JavaScript
    0   ???     0x13d408888        (pc == far, inside a Malloc Small region)
        lr = React`Scheduler::uiManagerDidFinishTransaction(...)::$_0::operator()() const + 88
    1   React   std::__invoke<Scheduler::uiManagerDidFinishTransaction(...)::$_0&> + 24
    ...
    7   React   RuntimeScheduler_Modern::updateRendering(HighResTimeStamp) + 316
    8   React   RuntimeScheduler_Modern::runEventLoopTick(jsi::Runtime&, Task&) + 388
    9   React   RuntimeScheduler_Modern::runEventLoop(jsi::Runtime&) + 188
    10  React   RuntimeScheduler_Modern::scheduleEventLoop()::$_0::operator()(jsi::Runtime&) const + 36
    17  React   ReactInstance::ReactInstance(...) runtime executor lambda + 164
    25  React   RCTMessageThread::tryFunc(std::function<void ()> const&) + 36
    38  React   +[RCTJSThreadManager runRunLoop] + 312
    

    $_0::operator() in the prebuilt 0.86.3 React.framework (UUID 581BE8B5-F252-3F2D-8931-FA5D1375BA22):

    ldrb  w8, [x8, #0x18]    ; guardEnabled
    tbz   w8, #0, call       ; flag off: the *invalidated check is skipped
    ...
    call:
    ldr   x0, [x8]           ; captured SchedulerDelegate*
    add   x1, x8, #0x20      ; &mountingCoordinator
    ldr   x8, [x0]           ; vptr read from the freed delegate
    ldr   x8, [x8, #0x8]     ; +80: slot 1, schedulerShouldRenderTransactions (2 reports fault here)
    blr   x8                 ; +84: jumps into the heap or to 0 (4 reports, lr = +88)
    

    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 old RCTHost on the main thread and builds a new one. -[RCTHost dealloc] → -[RCTInstance invalidate] → -[RCTSurfacePresenter suspend] releases the RCTScheduler. Its dealloc calls setDelegate(nullptr) and then frees _delegateProxy. The JS-thread cleanup in invalidate is 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 a url event. 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 during invalidate can trigger this. A pure-RN repro might be a screen that commits continuously while DevSettings.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.5 has 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.

  6. broboe commented on Oct 22, 2026

    @broboe

    Another data point: app termination is a trigger too. No reload and no JS error are involved.

    Environment: React Native 0.88.0, Expo SDK 58, New Architecture, iOS 26.6.2 and 27.2 (iPhone17,1, iPhone18,3), release builds from TestFlight. OTA updates are disabled. We also saw the same crash point on 0.86.3, where enableSchedulerDelegateInvalidation was present but off.

    What happens: In all 5 symbolicated reports, the main thread is inside -[UIApplication _terminateWithStatus:] and running UIApplicationWillTerminateNotification observers. RCTSurfacePresenter observes that notification and calls suspend, which sets _scheduler = nil and frees the Scheduler and its delegate. A uiManagerDidDispatchCommand callback already queued on the RuntimeScheduler then runs on the JS thread and dereferences the freed delegate. This seems to be the error-free teardown path that #58138 calls out as still open; handleTaskError never runs here.

    We think the queued command comes from a WebView injectJavaScript that we send when AppState changes to inactive, which happens during termination. That part is not confirmed.

    Crashed thread (identical in all 5 reports):

    Exception Type:  EXC_BAD_ACCESS (SIGSEGV)
    Exception Subtype: KERN_INVALID_ADDRESS at 0xa18ec06c777e8795
    
    Thread 2 Crashed:
    0   React  facebook::react::Scheduler::uiManagerDidDispatchCommand(std::__1::shared_ptr<facebook::react::ShadowNode const> const&, ... + 8 (Scheduler.cpp:336)
    5   React  std::__1::function<void ()>::operator()() const + 12 (function.h:989)
    7   React  facebook::react::RuntimeScheduler_Modern::updateRendering(facebook::jsi::Runtime&, facebook::react::HighResTimeStamp) + 208 (RuntimeScheduler_Modern.cpp:400)
    8   React  facebook::react::RuntimeScheduler_Modern::runEventLoopTick(facebook::jsi::Runtime&, facebook::react::Task&) + 292 (RuntimeScheduler_Modern.cpp:343)
    9   React  facebook::react::RuntimeScheduler_Modern::runEventLoop(facebook::jsi::Runtime&) + 112
    11  React  facebook::react::tryAndReturnError(std::__1::function<void ()> const&) + 32
    12  React  facebook::react::RCTMessageThread::tryFunc(std::__1::function<void ()> const&) + 24
    18  React  +[RCTJSThreadManager runRunLoop] + 252
    

    Main thread at the same moment (trimmed). The willTerminate observer shown varies between reports (Sentry's session tracker, SDWebImage's cache cleanup), but the frames from _terminateWithStatus down are the same in all of them:

    Thread 0:
    ...  <a UIApplicationWillTerminateNotification observer>
    35  CoreFoundation  __CFNOTIFICATIONCENTER_IS_CALLING_OUT_TO_AN_OBSERVER__ + 148
    39  Foundation      -[NSNotificationCenter postNotificationName:object:userInfo:] + 92
    40  UIKitCore       -[UIApplication _terminateWithStatus:] + 236
    41  UIKitCore       -[_UISceneLifecycleMultiplexer _evalTransitionToSettings:fromSettings:forceExit:withTransitionStore:] + 108
    42  UIKitCore       -[_UISceneLifecycleMultiplexer forceExitWithTransitionContext:scene:] + 156
    43  UIKitCore       -[UIApplication workspaceShouldExit:withTransitionContext:] + 180
    44  FrontBoardServices  __63-[FBSWorkspaceScenesClient willTerminateWithTransitionContext:]_block_invoke + 76
    ...
    58  UIKitCore       UIApplicationMain + 332
    

    The process is already exiting when this happens, so users don't notice anything. It still shows up as a fatal crash in TestFlight and in crash reporters. A scheduler shutdown signal, or turning the delegate guard back on, would close this path as well.

  7. erimicel commented on Oct 24, 2026

    @erimicel

    Same crash on React Native 0.88.0 (Expo SDK 58, prebuilt React.framework, bridgeless, Hermes, no OTA updates), App Store build on iPhone 17 Pro Max / iOS 27.0.1. It matches the termination path in the comment above.

    Symbolicated against the shipped React.framework (UUID 4CC272BD-9707-37A4-BB47-031DD15C68B2):

    EXC_BAD_ACCESS / KERN_INVALID_ADDRESS at 0xe5975971b5eedd16
    Thread: com.facebook.react.runtime.JavaScript
    0  React  Scheduler::uiManagerDidDispatchCommand(...)::$_0::operator()() + 8
    1  React  RuntimeScheduler_Modern::runEventLoopTick(jsi::Runtime&, Task&) + 288
    2  React  RuntimeScheduler_Modern::runEventLoop(jsi::Runtime&) + 108
    3  React  ReactInstance::ReactInstance(...) lambda + 128
    4  React  tryAndReturnError(std::function<void ()> const&) + 28
    5  React  RCTMessageThread::tryFunc / RCTMessageThread::runAsync
       ...
       React  +[RCTJSThreadManager runRunLoop]
    

    The faulting instruction is the vtable slot load for schedulerDidDispatchCommand:

    ldr x8, [x0, #0x8]    ; captured SchedulerDelegate*
    ldr x9, [x8]          ; vptr, read from freed memory
    ldr x4, [x9, #0x20]   ; faults
    

    Breadcrumbs: AppState inactive, then background 0.6 s later, then the crash under a second after that, with the app not in the foreground. That timing fits a force-quit from the app switcher straight after backgrounding: the app is still running, so iOS posts UIApplicationWillTerminateNotification, and -[RCTSurfacePresenter _applicationWillTerminate] suspends and frees the scheduler while a dispatched command is still queued. We don't know which command it was; JS was handling the background transition at the time. No reload and no JS error were involved.

    #58138 removed the guard because the error path is closed, but this error-free path (termination, plus the reload and dev-client cases above) still reaches a freed delegate on main. Restoring the invalidation token, or clearing pending rendering updates before RCTSurfacePresenter drops its scheduler, would cover it.

  8. ragamufin commented on Oct 25, 2026

    @ragamufin

    I can reproduce this from JS alone, with a project made from the Reproducer Template: https://github.com/ragamufin/react-native-58601-reproducer

    React Native 0.88.0 (latest), bridgeless, Hermes, debug build, iOS 26.5 simulator (iPhone 17 Pro), Xcode 27.1. The stack is the same: EXC_BAD_ACCESS on the JS thread in the Scheduler::uiManagerDidDispatchCommand lambda, called from RuntimeScheduler_Modern::updateRendering.

    It takes a view command (scrollTo, focus() or blur()) queued in the same JS task as DevSettings.reload(), and a JS thread that stays busy for about 100 ms afterwards. -[RCTInstance invalidate] runs [_surfacePresenter suspend] on the main thread before it dispatches its teardown block to the JS thread, so the delegate is freed while the deferred lambda still holds the raw delegate_. The app reloads itself in a loop and counts its reloads:

    • one scrollTo, then reload() and 100 ms of busy time: 19 of 20 reloads crash;
    • 20 queued commands: 46 of 52;
    • no busy time, or nothing queued (a focused field and a plain reload): 0 of 87.

    In our app (Expo SDK 58) it shows up as a crash on a Fast Refresh full reload with a focused field inside react-native-keyboard-controller's KeyboardAwareScrollView.

    On #53774, a comment suggests the trigger is a focused TextInput's unmount blur(), and that a JS-only playground can't reach the teardown. DevSettings.reload() reaches it from JS, and a focused field is not needed: any queued command will do. This is also not #44755 (overlapping reloads, fixed by throttling in #46416): these reloads come one at a time, seconds apart.

    Dismissing the keyboard and reloading one frame later drained the queued commands first: 0 crashes in 49 reloads. Commands queued at the reload() call itself still crash.

  9. BerkCalik commented on Oct 27, 2026

    @BerkCalik

    Measurements of the fix itself: with the invalidation guard from #56680 restored unconditionally, the reproducer goes from crashing on almost every reload to zero crashes.

    Setup: the reproducer unchanged (committed config.json), React Native 0.88.0 with core built from source (RCT_USE_PREBUILT_RNCORE=0), Debug, iOS 26.5 simulator, Xcode 26.6.

    Scheduler.cpp Duration Reloads Crashes
    as shipped in 0.88.0 240 s 38 37
    with the guard 240 s 198 0
    as shipped, same binary as the first run 240 s 37 37
    with the guard + a log line when it skips 120 s 97 0 (4173 stale uiManagerDidDispatchCommand lambdas skipped)

    The patch: the shared_ptr<atomic<bool>> token from #56680 with no feature flag. setDelegate() (when the delegate changes) and ~Scheduler() set it, setDelegate() installs a fresh token for the new delegate, and the lambdas deferred by uiManagerDidFinishTransaction and uiManagerDidDispatchCommand return early when it is set.

    This covers the reload path only. The teardown sequence is the one described in the expo-updates, dev-client and termination reports above, so the same guard should apply there, but I have not measured those paths.

    Limitation: the guard narrows the race but does not close it. If the main thread frees the delegate after a lambda reads the token and before it calls the delegate, the crash can still happen. Closing that fully would need a lock held across the delegate call, which risks a deadlock when the delegate hops to the main thread. These runs never hit that window.

    On the questions already asked here: if restoring the guard unconditionally is acceptable, I can open a PR against main with a test in ReactCommon/react/renderer/scheduler/tests and repeat these measurements on a main build.

  10. Pajn commented on Oct 31, 2026

    @Pajn
    Contributor

    We hit this crash on app termination too, on 0.86 and 0.88, and have been carrying the token from #56680 without its flag as a patch. Since #58138 and #58321 say the remaining window needs a fix that doesn't rely on a per-Scheduler token, here is a design that closes it on iOS without one. We'd like to upstream it if you agree with the approach.

    Why a flag can't close it

    The rendering-update queue belongs to the JS thread, and on iOS the delegate is freed on the main thread (-[RCTSurfacePresenter suspend], from both -[RCTInstance invalidate] and UIApplicationWillTerminateNotification). A flag checked by the queued lambda, whether in Scheduler or in RuntimeScheduler, still leaves the window between the check and the call. Closing it takes either a lock held across the delegate call, which makes the main thread wait on the JS thread, or keeping the delegate alive until the queued lambdas have run.

    The change: keep the delegate alive, in RCTScheduler.mm only

    Scheduler and RuntimeScheduler stay as they are.

    1. SchedulerDelegateProxy holds its RCTSchedulerDelegate weakly instead of holding RCTScheduler through an unretained __bridge void *. RCTScheduler.delegate reads and writes it, and dealloc sets it to nil, so a late call is a no-op.
    2. dealloc no longer frees the proxy on the main thread. It schedules a task on the runtime scheduler, which schedules a second task that holds the last reference. A rendering update runs at the end of the tick that queued it, or of the next tick when it was queued outside one, so by the time the second task runs, every update that captured the proxy's pointer has run.

    Nothing is locked on each update, no flag is checked, and the main thread never waits on the JS thread. Android doesn't need this: FabricUIManagerBinding is the delegate there and outlives the Scheduler. The lambda main adds in uiManagerDidFinishReactCommit is covered too, since it calls the same proxy.

    Measurements

    The reproducer from #58601 (comment), unchanged, with React Native 0.88.0 built from source (RCT_USE_PREBUILT_RNCORE=0), Debug, on an iOS 27 simulator with Xcode 27.2, 240 s per run:

    RCTScheduler.mm Reloads Crashes
    as shipped in 0.88.0 48 38, all in the uiManagerDidDispatchCommand lambda
    with the change 197 0

    A 60 s run with logging added counted 2,184 dispatch-command calls that reached the proxy after its RCTScheduler was gone, each one a no-op. Every proxy was released on the JS thread. We have also run our own app's iOS e2e suite on a Release build with the change, without regressions.

    The diff against 0.88.0
    --- a/packages/react-native/React/Fabric/RCTScheduler.mm
    +++ b/packages/react-native/React/Fabric/RCTScheduler.mm
    @@ -12,6 +12,7 @@
     #import <react/featureflags/ReactNativeFeatureFlags.h>
     #import <react/renderer/animations/LayoutAnimationDriver.h>
     #import <react/renderer/componentregistry/ComponentDescriptorFactory.h>
    +#import <react/renderer/runtimescheduler/RuntimeScheduler.h>
     #import <react/renderer/scheduler/Scheduler.h>
     #import <react/renderer/scheduler/SchedulerDelegate.h>
     #import <react/utils/RunLoopObserver.h>
    @@ -23,24 +24,29 @@
     
     class SchedulerDelegateProxy : public SchedulerDelegate {
      public:
    -  SchedulerDelegateProxy(void *scheduler) : scheduler_(scheduler) {}
    +  id<RCTSchedulerDelegate> getDelegate() const
    +  {
    +    return delegate_;
    +  }
     
    +  void setDelegate(id<RCTSchedulerDelegate> delegate)
    +  {
    +    delegate_ = delegate;
    +  }
    +
       void schedulerDidFinishTransaction(const std::shared_ptr<const MountingCoordinator> &mountingCoordinator) override
       {
    -    RCTScheduler *scheduler = (__bridge RCTScheduler *)scheduler_;
    -    [scheduler.delegate schedulerDidFinishTransaction:mountingCoordinator];
    +    [delegate_ schedulerDidFinishTransaction:mountingCoordinator];
       }
     
       void schedulerShouldRenderTransactions(const std::shared_ptr<const MountingCoordinator> &mountingCoordinator) override
       {
    -    RCTScheduler *scheduler = (__bridge RCTScheduler *)scheduler_;
    -    [scheduler.delegate schedulerShouldRenderTransactions:mountingCoordinator];
    +    [delegate_ schedulerShouldRenderTransactions:mountingCoordinator];
       }
     
       void schedulerShouldMergeReactRevision(SurfaceId surfaceId) override
       {
    -    RCTScheduler *scheduler = (__bridge RCTScheduler *)scheduler_;
    -    [scheduler.delegate schedulerShouldMergeReactRevision:surfaceId];
    +    [delegate_ schedulerShouldMergeReactRevision:surfaceId];
       }
     
       void schedulerDidRequestPreliminaryViewAllocation(const ShadowNode &shadowNode) override
    @@ -54,29 +60,25 @@
           const std::string &commandName,
           const folly::dynamic &args) override
       {
    -    RCTScheduler *scheduler = (__bridge RCTScheduler *)scheduler_;
    -    [scheduler.delegate schedulerDidDispatchCommand:shadowView commandName:commandName args:args];
    +    [delegate_ schedulerDidDispatchCommand:shadowView commandName:commandName args:args];
       }
     
       void schedulerDidSetIsJSResponder(const ShadowView &shadowView, bool isJSResponder, bool blockNativeResponder)
           override
       {
    -    RCTScheduler *scheduler = (__bridge RCTScheduler *)scheduler_;
    -    [scheduler.delegate schedulerDidSetIsJSResponder:isJSResponder
    -                                blockNativeResponder:blockNativeResponder
    -                                       forShadowView:shadowView];
    +    [delegate_ schedulerDidSetIsJSResponder:isJSResponder
    +                       blockNativeResponder:blockNativeResponder
    +                              forShadowView:shadowView];
       }
     
       void schedulerDidSendAccessibilityEvent(const ShadowView &shadowView, const std::string &eventType) override
       {
    -    RCTScheduler *scheduler = (__bridge RCTScheduler *)scheduler_;
    -    [scheduler.delegate schedulerDidSendAccessibilityEvent:shadowView eventType:eventType];
    +    [delegate_ schedulerDidSendAccessibilityEvent:shadowView eventType:eventType];
       }
     
       void schedulerShouldSynchronouslyUpdateViewOnUIThread(facebook::react::Tag tag, const folly::dynamic &props) override
       {
    -    RCTScheduler *scheduler = (__bridge RCTScheduler *)scheduler_;
    -    [scheduler.delegate schedulerDidSynchronouslyUpdateViewOnUIThread:tag props:props];
    +    [delegate_ schedulerDidSynchronouslyUpdateViewOnUIThread:tag props:props];
       }
     
       void schedulerDidUpdateShadowTree(const std::unordered_map<Tag, folly::dynamic> &tagToProps) override
    @@ -104,7 +106,7 @@
       }
     
      private:
    -  void *scheduler_;
    +  __weak id<RCTSchedulerDelegate> delegate_;
     };
     
     class LayoutAnimationDelegateProxy : public LayoutAnimationStatusDelegate, public RunLoopObserver::Delegate {
    @@ -187,7 +189,8 @@
     @implementation RCTScheduler {
       std::unique_ptr<Scheduler> _scheduler;
       std::shared_ptr<LayoutAnimationDriver> _animationDriver;
    -  std::unique_ptr<SchedulerDelegateProxy> _delegateProxy;
    +  std::shared_ptr<SchedulerDelegateProxy> _delegateProxy;
    +  std::weak_ptr<RuntimeScheduler> _runtimeScheduler;
       std::shared_ptr<LayoutAnimationDelegateProxy> _layoutAnimationDelegateProxy;
       std::unique_ptr<const PlatformRunLoopObserver> _uiRunLoopObserver;
     }
    @@ -195,8 +198,13 @@
     - (instancetype)initWithToolbox:(SchedulerToolbox)toolbox
     {
       if (self = [super init]) {
    -    _delegateProxy = std::make_unique<SchedulerDelegateProxy>((__bridge void *)self);
    +    _delegateProxy = std::make_shared<SchedulerDelegateProxy>();
     
    +    auto runtimeScheduler = toolbox.contextContainer->find<std::weak_ptr<RuntimeScheduler>>(RuntimeSchedulerKey);
    +    if (runtimeScheduler.has_value()) {
    +      _runtimeScheduler = runtimeScheduler.value();
    +    }
    +
         if (ReactNativeFeatureFlags::enableLayoutAnimationsOnIOS()) {
           _layoutAnimationDelegateProxy = std::make_shared<LayoutAnimationDelegateProxy>((__bridge void *)self);
           _animationDriver = std::make_shared<LayoutAnimationDriver>(
    @@ -219,6 +227,16 @@
       return self;
     }
     
    +- (id<RCTSchedulerDelegate>)delegate
    +{
    +  return _delegateProxy->getDelegate();
    +}
    +
    +- (void)setDelegate:(id<RCTSchedulerDelegate>)delegate
    +{
    +  _delegateProxy->setDelegate(delegate);
    +}
    +
     - (void)animationTick
     {
       _scheduler->animationTick();
    @@ -236,6 +254,21 @@
       }
     
       _scheduler->setDelegate(nullptr);
    +  _delegateProxy->setDelegate(nil);
    +
    +  // The JS thread may still hold rendering updates that call the proxy. Each one runs at the end of the event loop
    +  // tick that queued it, or of the next tick if it was queued outside one, so the proxy is released in a task
    +  // scheduled from a task: by then every update that captured it has run.
    +  if (auto runtimeScheduler = _runtimeScheduler.lock()) {
    +    runtimeScheduler->scheduleTask(
    +        SchedulerPriority::ImmediatePriority,
    +        [weakRuntimeScheduler = _runtimeScheduler, delegateProxy = _delegateProxy](facebook::jsi::Runtime &) {
    +          if (auto runtimeScheduler = weakRuntimeScheduler.lock()) {
    +            runtimeScheduler->scheduleTask(
    +                SchedulerPriority::ImmediatePriority, [delegateProxy](facebook::jsi::Runtime &) {});
    +          }
    +        });
    +  }
     }
     
     - (void)registerSurface:(const facebook::react::SurfaceHandler &)surfaceHandler

    What we'd send

    A PR against main with this change and an iOS unit test that frees the RCTScheduler while a rendering update is queued, then drains the queue. The existing SchedulerDelegateInvalidationTest drives a C++ delegate, so it doesn't exercise RCTScheduler.

    If you'd rather fix this below the host layer, the second step could become a RuntimeScheduler method that runs a callback after the rendering updates queued so far, so any host can release its delegate safely. We're happy to do it that way instead.

    Does either shape work for you?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    Needs: Author FeedbackNeeds: ReproThis issue could be improved with a clear list of steps to reproduce the issue.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions