Skip to content

Fix TextInput state revisions retaining their predecessor on Android - #58020

Open
entelostre wants to merge 1 commit into
react:mainfrom
entelostre:fix/android-textinput-state-retain-chain
Open

Fix TextInput state revisions retaining their predecessor on Android#58020
entelostre wants to merge 1 commit into
react:mainfrom
entelostre:fix/android-textinput-state-retain-chain

Conversation

@entelostre

Copy link
Copy Markdown

Summary:

#55719 added shadowViewFromShadowNode() to BaseTextInputShadowNode.h, nulling props/state on a fragment's parentShadowView so a text-input state revision does not retain the previous one. It shipped in 0.85.0.

AndroidTextInputShadowNode is final : public ConcreteViewShadowNode<...> — it does not inherit BaseTextInputShadowNode and builds its own fragment, so it never got the fix:

fragment.parentShadowView = ShadowView(*this);   // retains props + state

ShadowView::state points at the AndroidTextInputState held when the fragment was built, and updateStateIfNeeded() stores that fragment into the next state — so revision N retains N−1. The chain grows one link per update and is destroyed by nested recursion, exhausting the native stack (SIGSEGV on the guard page).

Still present in 0.85-stable and 0.86-stable as well as main. Android counterpart of #57193 (iOS, filed against 0.81.5, before #55719 shipped); that issue describes two problems — this patch addresses the unbounded retention, not the recursive destruction of the chain.

The fix nulls the two fields inline rather than reusing shadowViewFromShadowNode(), whose only copies are file-local in BaseTextShadowNode.cpp and in BaseTextInputShadowNode.h — a base class this node does not inherit. Happy to lift it into a shared header instead if you prefer.

Changelog:

[Android] [Fixed] - Fix TextInput native stack overflow caused by state revisions retaining their predecessor

Test Plan:

Safe: nothing in ReactCommon reads parentShadowView.props or .state — after this patch the only occurrences are the two assignments it adds. Consumers use tag, layoutMetrics, componentHandle and eventEmitter only. Measurement cache keys are unaffected: Fragment's hash and equality use .tag/.layoutMetrics directly and never std::hash<ShadowView>.

Sufficient: AndroidTextInputState::attributedStringBox has three writers — the initial state ({}, empty), updateStateIfNeeded() (stores getAttributedString() or the previous box), and the folly::dynamic constructor (copies the previous box). With the base case empty and getAttributedString() fixed here, induction gives that no fragment in either state field can carry a state pointer. Children are already covered by BaseTextShadowNode's own helper; the placeholder path uses {}.

Evidence: symbolicated from a production crash (llvm-addr2line -i, NDK 27, unstripped build-ID-matched libreactnative.so). The pattern repeats to stack exhaustion:

 4 __destroy_at<facebook::react::AndroidTextInputState const>
 5 facebook::react::State::~State()
 6 facebook::react::AttributedString::Fragment::~Fragment()
 7 __shared_ptr_emplace<facebook::react::AttributedString const>::__on_zero_shared()
 8 __destroy_at<facebook::react::AndroidTextInputState const>   ← next link
   ... repeats ...

~ShadowView is inlined into ~Fragment; its state member is the link this patch removes.

Measured on device. Two debug builds of the same app on a Samsung SM-G781B (Android 13), differing only by whether this patch is applied. Instrumentation in updateStateIfNeeded() walked state → attributedStringBox → fragments → parentShadowView.state and logged the chain length on every call:

Without the patch With the patch
Samples 4,929 4,673
State updates on busiest TextInput 920 1,090
Longest chain observed 791 0
Samples with a chain length above 0 many none

Without the patch, chain length scaled with the number of state updates and showed no upper bound — 3 updates → 1, 26 → 11, 128 → 101, 920 → 757, with a peak of 791 across all inputs in a three-minute session. With the patch, every one of 4,673 samples read 0, under a slightly higher update load. At 791 links, teardown is on the order of 3,000 nested destructor frames.

Still not verified: that this eliminates the crash in the field. That needs a release with the fix and a crash-rate comparison. There is also no unit test — there is no host flavour of components/textinput/platform/, no OSS CI job runs C++ tests, and Fantom does not link rrc_textinput. Happy to add one if you can point me at a target that would run it, and to give full detail on any of the above.

Affects 17 users across 16 Android device models (Lenovo, Samsung, Xiaomi, Oppo) in our production crash reporting, most recent 2026-08-14.

Summary:
react#55719 added shadowViewFromShadowNode() to BaseTextInputShadowNode.h,
nulling props/state on a fragment's parentShadowView so a text-input
state revision does not retain the previous one.

AndroidTextInputShadowNode is `final : public ConcreteViewShadowNode<...>`
and builds its own fragment for the input's text value, so it never
received that fix. ShadowView::state points at the AndroidTextInputState
held when the fragment was built, and that fragment is stored into the
next state by updateStateIfNeeded(), making revision N retain N-1. The
chain grows one link per update and is destroyed by nested recursion,
exhausting the native stack.

Nothing reads Fragment::parentShadowView.props or .state - only tag,
layoutMetrics and componentHandle are consumed - so clearing them is
behaviour-preserving, including on the MapBuffer measurement path.

Changelog:
[Android] [Fixed] - Fix TextInput state revisions retaining their predecessor, causing a native stack overflow after many text updates
@meta-cla

meta-cla Bot commented Aug 19, 2026

Copy link
Copy Markdown

Hi @entelostre!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant