You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[Due for payment 2026-06-04] [Sentry: APP-7B2] HybridApp iOS Fatal App Hang in Fabric ComponentDescriptorRegistry::findComponentDescriptorByHandle_DO_NOT_USE_THIS_IS_BROKEN #91292
Mechanism: AppHang, fatal — watchdog termination after 2000ms main-thread block
Stack trace (native, top frames)
main thread
-> facebook::react::ComponentDescriptorRegistry::findComponentDescriptorByHandle_DO_NOT_USE_THIS_IS_BROKEN
Suspected cause
The React Native Fabric infrastructure frame name explicitly flags an unsupported / known-broken lookup path. This is RN platform infrastructure (not application code), so the most likely outcome is a React Native version bump or a platform-team patch upstream.
Investigation suggestions
Re-symbolicate a recent event in Sentry to recover the JS-side context that triggers the descriptor lookup.
Check React Native release notes / issues for the findComponentDescriptorByHandle_DO_NOT_USE_THIS_IS_BROKEN symbol — it's referenced in a few Fabric-era RN bugs.
Compare iOS / device distribution with sibling Fabric crashes (APP-7CX, App#91229) to see if it correlates with the same device class.
Reproduction
Unknown deterministically — fires during the React commit phase under load.
Related
Sibling Sentry IDs (if any): none identified — this is a distinct hang path from APP-7CX (+[YAPLJS callFunction:args:]) and APP-7FJ (generic main).
The 2000ms+ hang is caused by mutex contention: when a background thread lazily registers a new component descriptor via add() (which takes a unique_lock on the same mutex), all shared_lock readers on the main thread block. In HybridApp, Fabric interop's lazy registration of legacy OldDot view managers amplifies this contention. Meta's own header comment on this function says "This is broken. Please do not use."
Contributing factors specific to this app:
Patches that disable view recycling for TextInput, Expo views, and Lottie increase Create/Delete mutation counts per transaction
HybridApp's OldDot main-thread work competes with Fabric mounting
What changes do you think we should make in order to solve the problem?
This is fundamentally an upstream React Native infrastructure issue — the function is internal to RN's Fabric renderer and is called by RN's own AnimatedMountingOverrideDelegate, UIManager, and RCTSurfacePresenter. The app cannot remove or bypass these call sites without patching RN.
Recommended approach — patch RN to eagerly register all component descriptors at startup, eliminating runtime add() calls that cause mutex contention:
Add a patch to ComponentDescriptorRegistry.cpp that pre-registers all known component descriptors during initialization rather than lazily during the mount phase
Alternatively, if the C++ native animated driver (cxxNativeAnimatedEnabled) is active, evaluate switching animated views to Reanimated (which bypasses AnimatedMountingOverrideDelegate entirely)
If patching is not viable, monitor for an upstream fix in RN 0.84+ — Meta is aware this function is broken (per the name and header comment) but has not yet replaced it. Filing an upstream RN issue linking this Sentry data would help prioritize the fix.
What alternative solutions did you explore? (Optional)
Re-enabling view recycling: Would reduce mutation counts and mount phase duration, but the recycling was disabled to fix other bugs (TextInput state leaks, Expo view issues) — re-enabling risks regressions
Moving mount work off the main thread: Not feasible without deep RN architecture changes; Fabric's mount phase is designed to run on the main thread for UIKit safety
Downgrading from Fabric: Would eliminate this code path entirely but is a major regression and defeats the purpose of the new architecture migration
Investigation details
Related issues:
Expensify/App#91229 — APP-7CX: HybridApp iOS Fatal App Hang in YAPLJS callFunction during onBarButtonItemSelected (sibling Fabric hang, different code path)
Expensify/App#91293 — APP-7FJ: HybridApp iOS Fatal App Hang at main (generic main-thread hang)
Evidence chain:
iOS watchdog kills the app after 2000ms main-thread block
The blocked frame is ComponentDescriptorRegistry::findComponentDescriptorByHandle_DO_NOT_USE_THIS_IS_BROKEN
This function is called from AnimatedMountingOverrideDelegate::pullTransaction (for each animated mutation) and RCTSurfacePresenter (for synchronous view updates)
The function acquires shared_lock(mutex_) — blocks when add() holds unique_lock on the same mutex
add() is triggered by Fabric interop lazily registering legacy view managers — more likely in HybridApp which bridges OldDot components
The entire mount pipeline (calculateShadowViewMutations → animated override → native mutations) runs under a scoped_lock on MountingCoordinator::mutex_, compounding the blocking time
Confidence: High — the mutex contention mechanism is clearly visible in the RN source code, and the HybridApp + Fabric interop combination creates the conditions for contention.
Next Steps for Contributor+ team: Reply with @MelvinBot implement this to create a draft PR, @MelvinBot <your feedback> to refine this analysis, or explain why you are rejecting Melvin's proposal.
🚨 Edited by proposal-police: This proposal was edited at 2026-05-21 11:58:04 UTC.
Proposal
Please re-state the problem that we are trying to solve in this issue.
iOS HybridApp users are hitting a fatal app hang while React Native Fabric is mounting or updating views. The main thread spends more than the watchdog budget in Fabric's component descriptor registry and iOS terminates the app.
What is the root cause of that problem?
The app ships React Native 0.83.1 and enables the iOS new architecture/Fabric path:
That method is named DO_NOT_USE_THIS_IS_BROKEN, but the hang mechanism is the registry locking around legacy/Fabric interop descriptor registration. RN 0.83.1 constructs component descriptors while holding registry locks. ComponentDescriptorProviderRegistry::add() holds the provider-registry mutex while it calls each registry->add(provider), and ComponentDescriptorRegistry::add() then holds its own unique lock while it runs the descriptor constructor:
That lookup may synchronously require main-thread work for ObjC modules/view managers. At the same time, the Fabric mount/update path on the main thread reads the same descriptor registry from call sites such as UIManagerUpdateShadowTree, AnimatedMountingOverrideDelegate, and RCTSurfacePresenter:
JS/background registration enters ComponentDescriptorRegistry::add(), holds the registry unique lock, and constructs a legacy interop descriptor.
That descriptor construction synchronously waits for the main thread through bridge/view-manager lookup.
The main thread is already in a Fabric transaction and tries to take a shared registry lock from findComponentDescriptorByHandle_DO_NOT_USE_THIS_IS_BROKEN.
The main thread waits for the registry lock, the registration thread waits for the main thread, and the iOS watchdog kills the app.
This matches the upstream RN issue for a ComponentDescriptorRegistry deadlock with legacy interop and main-thread synchronous lookup:
Changing only the findComponentDescriptorByHandle_DO_NOT_USE_THIS_IS_BROKEN call sites to name-based at(name) is not enough. The name-based lookup still takes the same registry shared lock and can also call providerRegistry_.request(...), so it can move the blocked frame without removing the lock inversion.
What changes do you think we should make in order to solve the problem?
Patch React Native 0.83.1 in patches/react-native so descriptor construction and legacy interop lookup do not run while descriptor registry locks are held.
In ComponentDescriptorRegistry::add(), construct the ComponentDescriptor before taking mutex_. Then acquire the unique lock only for the _registryByHandle / _registryByName insertion, with a duplicate-handle recheck before overwriting.
In ComponentDescriptorProviderRegistry::add(), hold mutex_ only long enough to validate/insert the provider and copy the live component registries to a local vector. Release the provider-registry lock before calling registry->add(provider) for each registry.
In ComponentDescriptorProviderRegistry::createComponentDescriptorRegistry(), copy the provider list under the provider-registry lock, register the new weak registry, then release the lock before populating the new registry with registry->add(...).
For iOS legacy interop, either enable the RN enableInteropViewManagerClassLookUpOptimizationIOS path or patch LegacyViewManagerInteropComponentDescriptor.mm to avoid lazily instantiating bridge modules just to resolve a view-manager class when RCTGetModuleClasses() can identify the class. This reduces the chance of RCTUnsafeExecuteOnMainQueueSync during descriptor construction.
Add the patch as the next available react-native+0.83.1+NNN+fix-fabric-component-descriptor-registry-deadlock.patch entry and document it in patches/react-native/details.md.
Add a focused native regression note/test plan: start HybridApp/new architecture on iOS with a legacy interop view manager that requires main-queue setup while a Fabric transaction reads the descriptor registry; the app should not hang. At minimum, run an iOS new-architecture build and verify the patched RN sources apply cleanly.
Scope boundaries: Files touched are limited to the RN patch under patches/react-native, covering ComponentDescriptorRegistry.cpp, ComponentDescriptorProviderRegistry.cpp, and the iOS legacy interop lookup path if needed, plus patches/react-native/details.md. This should not touch app JS screens, HybridApp TurboModule API, OldDot/YAPL bridge code, backend/API contracts, or web/mWeb rendering.
What alternative solutions did you explore? (Optional)
A pure handle-to-name lookup change is incomplete because both lookup paths use the same descriptor registry lock; it can change the symbol shown in Sentry without fixing the deadlock.
Eagerly registering all descriptors at startup may reduce runtime registration, but it still uses the same locked construction path and can reproduce the startup deadlock described upstream. It is safer to remove descriptor construction from the locked section.
Waiting for a React Native upgrade may eventually pick up an upstream fix, but this repo already carries targeted RN patches for production Fabric issues, and this failure is fatal for HybridApp users.
Disabling Fabric/new architecture would route around this path, but it is too broad for a Sentry group isolated to Fabric descriptor registry locking.
Updated my proposal - shifted the fix from handle-to-name descriptor lookup to the RN descriptor-registry lock inversion around legacy Fabric interop registration.
findComponentDescriptorByHandle_DO_NOT_USE_THIS_IS_BROKEN is a trivial reader — it takes a std::shared_lock and does one hashmap lookup (ComponentDescriptorRegistry.cpp#L116-L127). It cannot itself burn 2000 ms. An iOS AppHang stack is sampled at the moment the watchdog fires, so this frame is where the main thread is parked, not what is slow. The only way the main thread parks inside this function is blocked acquiring shared_lock(mutex_) — which happens only while another thread holds the matching unique_lock.
1. The writer holds the lock across descriptor construction (the real defect). That unique_lock is taken by ComponentDescriptorRegistry::add(), and the lock is held across the entireComponentDescriptor construction:
voidComponentDescriptorRegistry::add(...) const {
std::unique_lock lock(mutex_); // L33 — lock acquiredauto componentDescriptor = componentDescriptorProvider.constructor({...}); // L35 — heavy work UNDER the lock// ...asserts...
_registryByHandle[...] = sharedComponentDescriptor; // L48 — the only writes that need the lock
_registryByName[...] = sharedComponentDescriptor; // L50
}
add() is reached lazily during shadow-tree work via at(name) → providerRegistry_.request() → add() (#L62-L114), i.e. the first time a component (in HybridApp, a lazily-discovered OldDot legacy-interop view manager) is resolved. While that construction runs on the shadow thread, every main-thread reader of the registry blocks on shared_lock. std::shared_mutex is writer-favored, so the user-interactive main thread is fully stalled for the writer's wall-clock duration. Under HybridApp load the lower-QoS shadow thread can be descheduled mid-construction while OldDot runs its own main-thread work — a classic priority inversion that stretches the stall past the 2000 ms watchdog and kills the app, sampled at the busiest reader.
2. Why the busiest reader is this exact symbol. The highest-frequency main-thread caller is the legacy native-driven Animated path: RCTPropsAnimatedNode calls synchronouslyUpdateViewOnUIThread every frame (RCTPropsAnimatedNode.mm#L60-L70), which lands in RCTSurfacePresenter and calls the broken function on the main thread (RCTSurfacePresenter.mm#L157-L179). The synchronous shadow-tree update path reads it too (UIManagerUpdateShadowTree.cpp#L170-L177). A per-frame reader contending a per-component writer is exactly what an AppHang sampler catches most often — hence this frame, not because the lookup is slow.
What changes do you think we should make in order to solve the problem?
Move descriptor construction out of the unique_lock so the critical section is only the two hashmap writes. Construction touches neither map — it reads only parameters_ and the passed-in provider — so it is safe to build before locking. Add it as a patch-package patch (patches/react-native/react-native+0.83.1+037+component-descriptor-registry-add-lock.patch), in-pattern with the repo's existing Fabric patches (+018+fix-dropping-mutations-in-transactions, +033+fix-display-contents-dirty-flag, +009+copyStateOnClone):
This shrinks the writer's hold time from "construct a descriptor" to two map insertions (sub-microsecond), so a main-thread shared_lock reader can no longer be stalled — and priority inversion on mutex_ becomes impossible. The benign last-write-wins race (two threads constructing the same provider) is harmless: identical descriptors, and sibling registerComponentDescriptor() already writes the same maps with no extra synchronization (#L53-L60). It is a single-function change with no API or call-site impact, so every reader (at, the handle function, both call sites above) benefits.
Because an AppHang is sampled, the writer thread isn't in this event's top frame. I'd confirm the diagnosis on a symbolicated event by checking the full thread list for a shadow/JS thread inside ComponentDescriptorRegistry::add → ...::constructor while the main thread sits on shared_lock — but the change is correct by construction regardless: heavy work has no business inside a mutex that the main thread reads every frame.
What alternative solutions did you explore? (Optional)
Patch the readers to use name-based at() instead of the handle function.at(name)also takes shared_lock, and on a miss it unlocks, calls request() → add() (unique_lock + construction), then relocks (#L62-L114). Routing per-frame main-thread readers through it can trigger a write-lock and descriptor construction on the main thread itself — that fixes the function's correctness caveat (it returns nullptr for an unregistered handle) but makes the hang worse, not better. The fix should shrink the writer's critical section, not relocate it onto the reader.
AnimatedMountingOverrideDelegate per-mutation lookup. Its pullTransaction only runs when shouldOverridePullTransaction() is true (AnimatedMountingOverrideDelegate.cpp#L26-L31), which requires the C++ native-animated driver. cxxNativeAnimatedEnabled() defaults to false (ReactNativeFeatureFlagsDefaults.h#L38-L40) and the app sets no override, so this path is inactive here — not the source of this grouping.
"Eagerly register all descriptors at startup." Not viable for the legacy-interop view managers HybridApp resolves: their names are discovered on demand, so there is no complete startup set to pre-register. Shrinking add()'s critical section fixes the contention for exactly those lazily-added components.
Wait for an upstream RN bump / migrate to Reanimated. Leaves the current release exposed (~36 users / 14d, fatal) and Reanimated migration is far out of scope for a contention bug in one C++ function.
⚠️@yusufdeveloper2903 Your proposal is a duplicate of an already existing proposal and has been automatically withdrawn to prevent spam. Please review the existing proposals before submitting a new one.
Activity
MelvinBot commentedon May 21, 2026
Proposal
What is the root cause of that problem?
The hang occurs in React Native Fabric's main-thread mount pipeline. During the commit phase,
MountingCoordinator::pullTransactionacquires ascoped_lockand runs the shadow tree diff + mount synchronously on the main thread. Within this pipeline,AnimatedMountingOverrideDelegate::pullTransactioncallsfindComponentDescriptorByHandle_DO_NOT_USE_THIS_IS_BROKENfor every animated mutation, which acquires ashared_lockonComponentDescriptorRegistry::mutex_.The 2000ms+ hang is caused by mutex contention: when a background thread lazily registers a new component descriptor via
add()(which takes aunique_lockon the same mutex), allshared_lockreaders on the main thread block. In HybridApp, Fabric interop's lazy registration of legacy OldDot view managers amplifies this contention. Meta's own header comment on this function says "This is broken. Please do not use."Contributing factors specific to this app:
RCT_NEW_ARCH_ENABLED=1)What changes do you think we should make in order to solve the problem?
This is fundamentally an upstream React Native infrastructure issue — the function is internal to RN's Fabric renderer and is called by RN's own
AnimatedMountingOverrideDelegate,UIManager, andRCTSurfacePresenter. The app cannot remove or bypass these call sites without patching RN.Recommended approach — patch RN to eagerly register all component descriptors at startup, eliminating runtime
add()calls that cause mutex contention:ComponentDescriptorRegistry.cppthat pre-registers all known component descriptors during initialization rather than lazily during the mount phasecxxNativeAnimatedEnabled) is active, evaluate switching animated views to Reanimated (which bypassesAnimatedMountingOverrideDelegateentirely)If patching is not viable, monitor for an upstream fix in RN 0.84+ — Meta is aware this function is broken (per the name and header comment) but has not yet replaced it. Filing an upstream RN issue linking this Sentry data would help prioritize the fix.
What alternative solutions did you explore? (Optional)
Investigation details
Related issues:
YAPLJS callFunctionduringonBarButtonItemSelected(sibling Fabric hang, different code path)main(generic main-thread hang)Evidence chain:
ComponentDescriptorRegistry::findComponentDescriptorByHandle_DO_NOT_USE_THIS_IS_BROKENAnimatedMountingOverrideDelegate::pullTransaction(for each animated mutation) andRCTSurfacePresenter(for synchronous view updates)shared_lock(mutex_)— blocks whenadd()holdsunique_lockon the same mutexadd()is triggered by Fabric interop lazily registering legacy view managers — more likely in HybridApp which bridges OldDot componentscalculateShadowViewMutations→ animated override → native mutations) runs under ascoped_lockonMountingCoordinator::mutex_, compounding the blocking timeImpact: ~36 users over 14 days, iOS HybridApp only. Fatal (watchdog termination).
Confidence: High — the mutex contention mechanism is clearly visible in the RN source code, and the HybridApp + Fabric interop combination creates the conditions for contention.
Next Steps for Contributor+ team: Reply with
@MelvinBot implement thisto create a draft PR,@MelvinBot <your feedback>to refine this analysis, or explain why you are rejecting Melvin's proposal.melvin-bot commentedon May 21, 2026
Triggered auto assignment to Contributor-plus team member for initial proposal review - @jayeshmangwani (
External)melvin-bot commentedon May 21, 2026
Unable to auto-create job on Upwork. The BZ team member should create it manually for this issue.
KJ21-ENG commentedon May 21, 2026
🚨 Edited by proposal-police: This proposal was edited at 2026-05-21 11:58:04 UTC.
Proposal
Please re-state the problem that we are trying to solve in this issue.
iOS HybridApp users are hitting a fatal app hang while React Native Fabric is mounting or updating views. The main thread spends more than the watchdog budget in Fabric's component descriptor registry and iOS terminates the app.
What is the root cause of that problem?
The app ships React Native 0.83.1 and enables the iOS new architecture/Fabric path:
App/package.json
Lines 161 to 162 in c8af8b3
App/ios/Podfile
Lines 81 to 84 in c8af8b3
The Sentry frame is a Fabric registry read:
https://github.com/facebook/react-native/blob/541213e85519d3cc5b9a84f0990a88654b4a1a94/packages/react-native/ReactCommon/react/renderer/componentregistry/ComponentDescriptorRegistry.cpp#L116-L127
That method is named
DO_NOT_USE_THIS_IS_BROKEN, but the hang mechanism is the registry locking around legacy/Fabric interop descriptor registration. RN 0.83.1 constructs component descriptors while holding registry locks.ComponentDescriptorProviderRegistry::add()holds the provider-registry mutex while it calls eachregistry->add(provider), andComponentDescriptorRegistry::add()then holds its own unique lock while it runs the descriptor constructor:https://github.com/facebook/react-native/blob/541213e85519d3cc5b9a84f0990a88654b4a1a94/packages/react-native/ReactCommon/react/renderer/componentregistry/ComponentDescriptorProviderRegistry.cpp#L12-L41
https://github.com/facebook/react-native/blob/541213e85519d3cc5b9a84f0990a88654b4a1a94/packages/react-native/ReactCommon/react/renderer/componentregistry/ComponentDescriptorRegistry.cpp#L31-L50
For iOS legacy view-manager interop, that constructor can look up a legacy view manager through the bridge/bridge proxy:
https://github.com/facebook/react-native/blob/541213e85519d3cc5b9a84f0990a88654b4a1a94/packages/react-native/ReactCommon/react/renderer/components/legacyviewmanagerinterop/LegacyViewManagerInteropComponentDescriptor.mm#L94-L101
https://github.com/facebook/react-native/blob/541213e85519d3cc5b9a84f0990a88654b4a1a94/packages/react-native/ReactCommon/react/renderer/components/legacyviewmanagerinterop/LegacyViewManagerInteropComponentDescriptor.mm#L139-L152
That lookup may synchronously require main-thread work for ObjC modules/view managers. At the same time, the Fabric mount/update path on the main thread reads the same descriptor registry from call sites such as
UIManagerUpdateShadowTree,AnimatedMountingOverrideDelegate, andRCTSurfacePresenter:https://github.com/facebook/react-native/blob/541213e85519d3cc5b9a84f0990a88654b4a1a94/packages/react-native/ReactCommon/react/renderer/uimanager/UIManagerUpdateShadowTree.cpp#L168-L177
https://github.com/facebook/react-native/blob/541213e85519d3cc5b9a84f0990a88654b4a1a94/packages/react-native/ReactCommon/react/renderer/animated/internal/AnimatedMountingOverrideDelegate.cpp#L70-L81
https://github.com/facebook/react-native/blob/541213e85519d3cc5b9a84f0990a88654b4a1a94/packages/react-native/React/Fabric/RCTSurfacePresenter.mm#L164-L178
So the deadlock/hang is:
ComponentDescriptorRegistry::add(), holds the registry unique lock, and constructs a legacy interop descriptor.findComponentDescriptorByHandle_DO_NOT_USE_THIS_IS_BROKEN.This matches the upstream RN issue for a
ComponentDescriptorRegistrydeadlock with legacy interop and main-thread synchronous lookup:react/react-native#53128
Changing only the
findComponentDescriptorByHandle_DO_NOT_USE_THIS_IS_BROKENcall sites to name-basedat(name)is not enough. The name-based lookup still takes the same registry shared lock and can also callproviderRegistry_.request(...), so it can move the blocked frame without removing the lock inversion.What changes do you think we should make in order to solve the problem?
Patch React Native 0.83.1 in
patches/react-nativeso descriptor construction and legacy interop lookup do not run while descriptor registry locks are held.ComponentDescriptorRegistry::add(), construct theComponentDescriptorbefore takingmutex_. Then acquire the unique lock only for the_registryByHandle/_registryByNameinsertion, with a duplicate-handle recheck before overwriting.ComponentDescriptorProviderRegistry::add(), holdmutex_only long enough to validate/insert the provider and copy the live component registries to a local vector. Release the provider-registry lock before callingregistry->add(provider)for each registry.ComponentDescriptorProviderRegistry::createComponentDescriptorRegistry(), copy the provider list under the provider-registry lock, register the new weak registry, then release the lock before populating the new registry withregistry->add(...).enableInteropViewManagerClassLookUpOptimizationIOSpath or patchLegacyViewManagerInteropComponentDescriptor.mmto avoid lazily instantiating bridge modules just to resolve a view-manager class whenRCTGetModuleClasses()can identify the class. This reduces the chance ofRCTUnsafeExecuteOnMainQueueSyncduring descriptor construction.react-native+0.83.1+NNN+fix-fabric-component-descriptor-registry-deadlock.patchentry and document it inpatches/react-native/details.md.Scope boundaries: Files touched are limited to the RN patch under
patches/react-native, coveringComponentDescriptorRegistry.cpp,ComponentDescriptorProviderRegistry.cpp, and the iOS legacy interop lookup path if needed, pluspatches/react-native/details.md. This should not touch app JS screens, HybridApp TurboModule API, OldDot/YAPL bridge code, backend/API contracts, or web/mWeb rendering.What alternative solutions did you explore? (Optional)
A pure handle-to-name lookup change is incomplete because both lookup paths use the same descriptor registry lock; it can change the symbol shown in Sentry without fixing the deadlock.
Eagerly registering all descriptors at startup may reduce runtime registration, but it still uses the same locked construction path and can reproduce the startup deadlock described upstream. It is safer to remove descriptor construction from the locked section.
Waiting for a React Native upgrade may eventually pick up an upstream fix, but this repo already carries targeted RN patches for production Fabric issues, and this failure is fatal for HybridApp users.
Disabling Fabric/new architecture would route around this path, but it is too broad for a Sentry group isolated to Fabric descriptor registry locking.
KJ21-ENG commentedon May 21, 2026
Updated my proposal - shifted the fix from handle-to-name descriptor lookup to the RN descriptor-registry lock inversion around legacy Fabric interop registration.
yusufdeveloper2903 commentedon May 21, 2026
Proposal
What is the root cause of that problem?
findComponentDescriptorByHandle_DO_NOT_USE_THIS_IS_BROKENis a trivial reader — it takes astd::shared_lockand does one hashmap lookup (ComponentDescriptorRegistry.cpp#L116-L127). It cannot itself burn 2000 ms. An iOSAppHangstack is sampled at the moment the watchdog fires, so this frame is where the main thread is parked, not what is slow. The only way the main thread parks inside this function is blocked acquiringshared_lock(mutex_)— which happens only while another thread holds the matchingunique_lock.1. The writer holds the lock across descriptor construction (the real defect). That
unique_lockis taken byComponentDescriptorRegistry::add(), and the lock is held across the entireComponentDescriptorconstruction:ComponentDescriptorRegistry.cpp#L31-L51add()is reached lazily during shadow-tree work viaat(name)→providerRegistry_.request()→add()(#L62-L114), i.e. the first time a component (in HybridApp, a lazily-discovered OldDot legacy-interop view manager) is resolved. While that construction runs on the shadow thread, every main-thread reader of the registry blocks onshared_lock.std::shared_mutexis writer-favored, so the user-interactive main thread is fully stalled for the writer's wall-clock duration. Under HybridApp load the lower-QoS shadow thread can be descheduled mid-construction while OldDot runs its own main-thread work — a classic priority inversion that stretches the stall past the 2000 ms watchdog and kills the app, sampled at the busiest reader.2. Why the busiest reader is this exact symbol. The highest-frequency main-thread caller is the legacy native-driven
Animatedpath:RCTPropsAnimatedNodecallssynchronouslyUpdateViewOnUIThreadevery frame (RCTPropsAnimatedNode.mm#L60-L70), which lands inRCTSurfacePresenterand calls the broken function on the main thread (RCTSurfacePresenter.mm#L157-L179). The synchronous shadow-tree update path reads it too (UIManagerUpdateShadowTree.cpp#L170-L177). A per-frame reader contending a per-component writer is exactly what anAppHangsampler catches most often — hence this frame, not because the lookup is slow.What changes do you think we should make in order to solve the problem?
Move descriptor construction out of the
unique_lockso the critical section is only the two hashmap writes. Construction touches neither map — it reads onlyparameters_and the passed-in provider — so it is safe to build before locking. Add it as a patch-package patch (patches/react-native/react-native+0.83.1+037+component-descriptor-registry-add-lock.patch), in-pattern with the repo's existing Fabric patches (+018+fix-dropping-mutations-in-transactions,+033+fix-display-contents-dirty-flag,+009+copyStateOnClone):void ComponentDescriptorRegistry::add( const ComponentDescriptorProvider& componentDescriptorProvider) const { - std::unique_lock lock(mutex_); - auto componentDescriptor = componentDescriptorProvider.constructor( {.eventDispatcher = parameters_.eventDispatcher, .contextContainer = parameters_.contextContainer, .flavor = componentDescriptorProvider.flavor}); react_native_assert( componentDescriptor->getComponentHandle() == componentDescriptorProvider.handle); react_native_assert( componentDescriptor->getComponentName() == componentDescriptorProvider.name); auto sharedComponentDescriptor = std::shared_ptr<const ComponentDescriptor>( std::move(componentDescriptor)); + + std::unique_lock lock(mutex_); _registryByHandle[componentDescriptorProvider.handle] = sharedComponentDescriptor; _registryByName[componentDescriptorProvider.name] = sharedComponentDescriptor; }This shrinks the writer's hold time from "construct a descriptor" to two map insertions (sub-microsecond), so a main-thread
shared_lockreader can no longer be stalled — and priority inversion onmutex_becomes impossible. The benign last-write-wins race (two threads constructing the same provider) is harmless: identical descriptors, and siblingregisterComponentDescriptor()already writes the same maps with no extra synchronization (#L53-L60). It is a single-function change with no API or call-site impact, so every reader (at, the handle function, both call sites above) benefits.Because an
AppHangis sampled, the writer thread isn't in this event's top frame. I'd confirm the diagnosis on a symbolicated event by checking the full thread list for a shadow/JS thread insideComponentDescriptorRegistry::add→...::constructorwhile the main thread sits onshared_lock— but the change is correct by construction regardless: heavy work has no business inside a mutex that the main thread reads every frame.What alternative solutions did you explore? (Optional)
at()instead of the handle function.at(name)also takesshared_lock, and on a miss it unlocks, callsrequest()→add()(unique_lock+ construction), then relocks (#L62-L114). Routing per-frame main-thread readers through it can trigger a write-lock and descriptor construction on the main thread itself — that fixes the function's correctness caveat (it returnsnullptrfor an unregistered handle) but makes the hang worse, not better. The fix should shrink the writer's critical section, not relocate it onto the reader.AnimatedMountingOverrideDelegateper-mutation lookup. ItspullTransactiononly runs whenshouldOverridePullTransaction()is true (AnimatedMountingOverrideDelegate.cpp#L26-L31), which requires the C++ native-animated driver.cxxNativeAnimatedEnabled()defaults tofalse(ReactNativeFeatureFlagsDefaults.h#L38-L40) and the app sets no override, so this path is inactive here — not the source of this grouping.add()'s critical section fixes the contention for exactly those lazily-added components.github-actions commentedon May 21, 2026
35 remaining items
mallenexpensify commentedon Jun 5, 2026
Payment Summary
Contributor+: @jayeshmangwani due $250 via NewDot
mountiny commentedon Jul 2, 2026
Reopening from weekly Sentry audit — APP-7B2 is still active despite Sentry showing resolved:
ComponentDescriptorRegistry::findComponentDescriptorByHandle_DO_NOT_USE_THIS_IS_BROKENduring RN animated node updateLikely premature Sentry resolve or regression. Parent tracker: #91302.
JakubKorytko commentedon Jul 6, 2026
@mountiny the Sentry has the wrong version in "resolved". It says I have resolved it but to be fair I don't remember doing that - automatic I guess?
Melvin:
Sentry:
There are 0 errors in 9.3.88-2 and higher, see. I have changed the version in Sentry to 9.3.88-2.
mountiny commentedon Jul 6, 2026
Cool, thank you
quinthar commentedon Jul 31, 2026
Approved $250 to @jayeshmangwani