Skip to content

[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

Closed

Description

@mountiny

Sentry

https://expensify.sentry.io/issues/APP-7B2

Impact (snapshot at filing)

  • Users (last 14d): ~36
  • Platform: iOS, hybrid_app (HybridApp)
  • 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).
  • Prior GH issues: none.
Issue OwnerCurrent Issue Owner: @mallenexpensify

Activity

  1. self-assigned this
    on May 21, 2026
  2. added
    BugSomething is broken. Auto assigns a BugZero manager.
    on May 21, 2026
  3. MelvinBot commented on May 21, 2026

    @MelvinBot
    Contributor

    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::pullTransaction acquires a scoped_lock and runs the shadow tree diff + mount synchronously on the main thread. Within this pipeline, AnimatedMountingOverrideDelegate::pullTransaction calls findComponentDescriptorByHandle_DO_NOT_USE_THIS_IS_BROKEN for every animated mutation, which acquires a shared_lock on ComponentDescriptorRegistry::mutex_.

    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
    • The app uses RN 0.83.1 with Fabric fully enabled (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, 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:

    1. Add a patch to ComponentDescriptorRegistry.cpp that pre-registers all known component descriptors during initialization rather than lazily during the mount phase
    2. 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:

    1. iOS watchdog kills the app after 2000ms main-thread block
    2. The blocked frame is ComponentDescriptorRegistry::findComponentDescriptorByHandle_DO_NOT_USE_THIS_IS_BROKEN
    3. This function is called from AnimatedMountingOverrideDelegate::pullTransaction (for each animated mutation) and RCTSurfacePresenter (for synchronous view updates)
    4. The function acquires shared_lock(mutex_) — blocks when add() holds unique_lock on the same mutex
    5. add() is triggered by Fabric interop lazily registering legacy view managers — more likely in HybridApp which bridges OldDot components
    6. The entire mount pipeline (calculateShadowViewMutations → animated override → native mutations) runs under a scoped_lock on MountingCoordinator::mutex_, compounding the blocking time

    Impact: ~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 this to create a draft PR, @MelvinBot <your feedback> to refine this analysis, or explain why you are rejecting Melvin's proposal.

  4. added
    ExternalAdded to denote the issue can be worked on by a contributor
    on May 21, 2026
  5. added
    Help WantedApply this label when an issue is open to proposals by contributors
    on May 21, 2026
  6. melvin-bot commented on May 21, 2026

    @melvin-bot

    Triggered auto assignment to Contributor-plus team member for initial proposal review - @jayeshmangwani (External)

  7. melvin-bot commented on May 21, 2026

    @melvin-bot

    Unable to auto-create job on Upwork. The BZ team member should create it manually for this issue.

  8. KJ21-ENG commented on May 21, 2026

    @KJ21-ENG
    Contributor

    🚨 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

    "react-native": "0.83.1",
    "react-native-advanced-input-mask": "1.4.6",

    App/ios/Podfile

    Lines 81 to 84 in c8af8b3

    # ENV Variable enables/disables TurboModules
    ENV['RCT_NEW_ARCH_ENABLED'] = '1';
    # ENV Variable enables/disables Hermes V1
    ENV['RCT_HERMES_V1_ENABLED'] = '1';

    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 each registry->add(provider), and ComponentDescriptorRegistry::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, and RCTSurfacePresenter:

    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:

    1. JS/background registration enters ComponentDescriptorRegistry::add(), holds the registry unique lock, and constructs a legacy interop descriptor.
    2. That descriptor construction synchronously waits for the main thread through bridge/view-manager lookup.
    3. 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.
    4. 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:

    react/react-native#53128

    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.

    1. 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.
    2. 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.
    3. 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(...).
    4. 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.
    5. 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.
    6. 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.

  9. KJ21-ENG commented on May 21, 2026

    @KJ21-ENG
    Contributor

    Updated my proposal - shifted the fix from handle-to-name descriptor lookup to the RN descriptor-registry lock inversion around legacy Fabric interop registration.

  10. yusufdeveloper2903 commented on May 21, 2026

    @yusufdeveloper2903
    Contributor

    Proposal

    What is the root cause of that problem?

    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 entire ComponentDescriptor construction:

    ComponentDescriptorRegistry.cpp#L31-L51

    void ComponentDescriptorRegistry::add(...) const {
      std::unique_lock lock(mutex_);                              // L33 — lock acquired
      auto 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):

     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_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.
  11. github-actions commented on May 21, 2026

    @github-actions
    Contributor

    ⚠️ @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.

  12. 35 remaining items

  13. mallenexpensify commented on Jun 5, 2026

    @mallenexpensify
    Contributor

    Payment Summary

    Contributor+: @jayeshmangwani due $250 via NewDot

  14. mountiny commented on Jul 2, 2026

    @mountiny
    ContributorAuthor

    Reopening from weekly Sentry audit — APP-7B2 is still active despite Sentry showing resolved:

    • Users (last 7d): 49
    • Events (last 7d): 61
    • Last seen: 2026-07-02
    • Platform: iOS HybridApp
    • App version(s): 9.3.63 (and others)
    • Culprit: ComponentDescriptorRegistry::findComponentDescriptorByHandle_DO_NOT_USE_THIS_IS_BROKEN during RN animated node update

    Likely premature Sentry resolve or regression. Parent tracker: #91302.

  15. moved this from Done to SUBISSUE in [#whatsnext] #qualityon Jul 2, 2026
  16. removed their assignment
    on Jul 2, 2026
  17. JakubKorytko commented on Jul 6, 2026

    @JakubKorytko
    Member

    @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:

    🚀 deployed to production 🚀 in version 9.3.88-2

    Sentry:

    Jakub Korytko marked this issue as resolved in version [9.3.81-1]

    There are 0 errors in 9.3.88-2 and higher, see. I have changed the version in Sentry to 9.3.88-2.

  18. mountiny commented on Jul 6, 2026

    @mountiny
    ContributorAuthor

    Cool, thank you

  19. quinthar commented on Jul 31, 2026

    @quinthar
    Contributor

    Approved $250 to @jayeshmangwani

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

Metadata

Metadata

Labels

Awaiting PaymentAuto-added when associated PR is deployed to productionBugSomething is broken. Auto assigns a BugZero manager.DailyKSv2ExternalAdded to denote the issue can be worked on by a contributor

Type

No type

Projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions