Description
After a session of normal typing/interaction with <TextInput> components, the app crashes with:
EXC_BAD_ACCESS (SIGBUS), KERN_PROTECTION_FAILURE
"Thread stack size exceeded due to excessive recursion"
Faulting thread: "hades" (Hermes GC executor)
The faulting stack is a repeating recursive destructor cycle (~36 frames per link), unwinding a linked chain of text-input state revisions:
TextInputState::~TextInputState()
→ AttributedString::~AttributedString() (reactTreeAttributedString)
→ AttributedString::Fragment::~Fragment()
→ ShadowView::~ShadowView() (Fragment::parentShadowView)
→ shared_ptr<State const>::~shared_ptr (ShadowView::state)
→ ConcreteState<TextInputState>::~ConcreteState()
→ TextInputState::~TextInputState() ← previous revision, recurse
Mechanism (verified against 0.81.5 sources)
Each text-input state revision retains the previous revision:
TextInputState stores the input's content as AttributedString reactTreeAttributedString
(ReactCommon/react/renderer/components/textinput/TextInputState.h:45)
- Each
AttributedString::Fragment carries a full ShadowView parentShadowView
(ReactCommon/react/renderer/attributedstring/AttributedString.h:33)
ShadowView retains State::Shared state — the state the node had when the fragment
was built, i.e. the previous TextInputState
So every state revision (keystroke, native text update, re-commit) appends one link to an
unbounded chain. Nothing trims it. When the last reference is finally dropped from JS, the
Hermes GC releases it on its background executor thread ("hades"), whose stack is small —
and the chain is destroyed via nested destructor recursion, one ~36-frame cascade per
revision. A few hundred to a few thousand accumulated revisions on one input is enough to
overflow that thread's stack.
Two distinct problems compound here:
- Unbounded retention: state revision N should not transitively retain revisions
N-1…0 through reactTreeAttributedString fragments' parentShadowView.state.
- Recursive destruction on a small-stack thread: even bounded chains are destroyed
recursively; on the GC executor thread the headroom is far less than the main/JS thread,
so the crash appears "randomly" depending on which thread drops the last reference.
Steps to reproduce
Observed in a production-style app (Expo SDK 54, new architecture), not yet minimized:
- Mount a controlled multiline
<TextInput value={text} onChangeText={setText} />
- Generate many state revisions on that one mount (typing/editing — hundreds+)
- Unmount / navigate away and let the Hermes GC collect the retained ShadowNode reference
- Crash on the
hades thread with the stack above
Full .ips crash report available on request.
Expected behavior
Old TextInputState revisions are released incrementally (or destroyed iteratively), and
no recursion proportional to edit count occurs on the GC thread.
Environment
- React Native: 0.81.5 (Fabric / new architecture, Hermes)
- Expo SDK 54 (dev client)
- Observed on: iOS 26.3 simulator (iPhone 16e), macOS 26.3 / MacBookPro18,1 — but the
mechanism is platform-independent C++ in the renderer; nothing simulator-specific
- The TextInputs involved are ordinary controlled inputs (no custom native code touching them)
Crash excerpt (faulting thread, first link of the cycle)
0 React std::__1::__shared_count::__release_shared()
4 React facebook::react::State::~State()
5 React facebook::react::ConcreteState<facebook::react::TextInputState>::~ConcreteState()
15 React facebook::react::ShadowView::~ShadowView()
17 React facebook::react::AttributedString::Fragment::~Fragment()
26 React facebook::react::AttributedString::~AttributedString()
28 React facebook::react::TextInputState::~TextInputState()
34 React std::__1::__shared_count::__release_shared() ← next link, repeats to stack exhaustion
…
hermes::vm::HadesGC::collectOGInBackground()
hermes::vm::HadesGC::Executor::worker()
_pthread_start
Description
After a session of normal typing/interaction with
<TextInput>components, the app crashes with:The faulting stack is a repeating recursive destructor cycle (~36 frames per link), unwinding a linked chain of text-input state revisions:
Mechanism (verified against 0.81.5 sources)
Each text-input state revision retains the previous revision:
TextInputStatestores the input's content asAttributedString reactTreeAttributedString(
ReactCommon/react/renderer/components/textinput/TextInputState.h:45)AttributedString::Fragmentcarries a fullShadowView parentShadowView(
ReactCommon/react/renderer/attributedstring/AttributedString.h:33)ShadowViewretainsState::Shared state— the state the node had when the fragmentwas built, i.e. the previous
TextInputStateSo every state revision (keystroke, native text update, re-commit) appends one link to an
unbounded chain. Nothing trims it. When the last reference is finally dropped from JS, the
Hermes GC releases it on its background executor thread ("hades"), whose stack is small —
and the chain is destroyed via nested destructor recursion, one ~36-frame cascade per
revision. A few hundred to a few thousand accumulated revisions on one input is enough to
overflow that thread's stack.
Two distinct problems compound here:
N-1…0 through
reactTreeAttributedStringfragments'parentShadowView.state.recursively; on the GC executor thread the headroom is far less than the main/JS thread,
so the crash appears "randomly" depending on which thread drops the last reference.
Steps to reproduce
Observed in a production-style app (Expo SDK 54, new architecture), not yet minimized:
<TextInput value={text} onChangeText={setText} />hadesthread with the stack aboveFull
.ipscrash report available on request.Expected behavior
Old
TextInputStaterevisions are released incrementally (or destroyed iteratively), andno recursion proportional to edit count occurs on the GC thread.
Environment
mechanism is platform-independent C++ in the renderer; nothing simulator-specific
Crash excerpt (faulting thread, first link of the cycle)