Description
FabricUIManagerBinding::schedulerDidFinishTransaction (Android, ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.cpp) selects which pending mounting transaction an incoming transaction should merge into using a forward search:
auto pendingTransaction = std::find_if(
pendingTransactions_.begin(),
pendingTransactions_.end(),
[&](const auto& transaction) {
return transaction.getSurfaceId() == mountingTransaction->getSurfaceId();
});
if (pendingTransaction != pendingTransactions_.end() &&
pendingTransaction->canMergeWith(*mountingTransaction)) {
pendingTransaction->mergeWith(std::move(*mountingTransaction));
} else {
pendingTransactions_.push_back(std::move(*mountingTransaction));
}
std::find_if over begin()/end() returns the oldest queued transaction for a surface. Whenever a surface has more than one pending transaction at once — which can legitimately happen any time MountingTransaction::canMergeWith refuses a merge between two adjacent transactions — this picks the wrong merge target.
Repro scenario
Given pending transactions T1, T2 for the same surface (already queued separately because canMergeWith refused to combine them), and an incoming T3:
T3 was diffed by the renderer against shadow-tree state that already includes T2.
- The forward search finds
T1 first and merges T3 into it, producing [T1+T3, T2], which executes as T1 → T3 → T2.
- But
T3 was never diffed against a tree without T2 in it — running it before T2 desyncs the native view tree from the shadow tree that produced the diff.
This manifests as native-tree/shadow-tree divergence at mount-apply time: an insert lands at an index the real parent doesn't have (IndexOutOfBoundsException in addViewAt), or a remove resolves a stale parent tag that's no longer the expected ViewGroup (IllegalStateException in removeViewAt).
Expected behavior
A new transaction should only ever merge into the most recently queued pending transaction for its surface — the only one whose diffed-against tree state actually reflects everything already queued. That means the merge-target lookup should search from the back (rbegin()/rend()) instead of the front (begin()/end()).
Fix
Three-token change:
auto pendingTransaction = std::find_if(
pendingTransactions_.rbegin(),
pendingTransactions_.rend(),
[&](const auto& transaction) {
return transaction.getSurfaceId() == mountingTransaction->getSurfaceId();
});
if (pendingTransaction != pendingTransactions_.rend() &&
pendingTransaction->canMergeWith(*mountingTransaction)) {
...
Environment
React Native 0.86.0, Android (Fabric), observed downstream in a fork carrying an additional local patch that makes canMergeWith refuse certain merges (a Delete↔Create tag-pairing guard), which is what surfaces multi-entry pendingTransactions_ queues in practice. The underlying merge-target selection bug is present in schedulerDidFinishTransaction upstream regardless of that local patch — any code path that causes canMergeWith to refuse a merge (including future upstream guards) would trigger the same divergence.
Reproducibility
No deterministic manual repro is provided here — the bug is timing/queue-state dependent. The mechanism is provable deterministically with a unit test against MountingTransaction directly (construct 3 transactions, assert execution order), without needing device timing.
Description
FabricUIManagerBinding::schedulerDidFinishTransaction(Android,ReactAndroid/src/main/jni/react/fabric/FabricUIManagerBinding.cpp) selects which pending mounting transaction an incoming transaction should merge into using a forward search:std::find_ifoverbegin()/end()returns the oldest queued transaction for a surface. Whenever a surface has more than one pending transaction at once — which can legitimately happen any timeMountingTransaction::canMergeWithrefuses a merge between two adjacent transactions — this picks the wrong merge target.Repro scenario
Given pending transactions
T1,T2for the same surface (already queued separately becausecanMergeWithrefused to combine them), and an incomingT3:T3was diffed by the renderer against shadow-tree state that already includesT2.T1first and mergesT3into it, producing[T1+T3, T2], which executes asT1 → T3 → T2.T3was never diffed against a tree withoutT2in it — running it beforeT2desyncs the native view tree from the shadow tree that produced the diff.This manifests as native-tree/shadow-tree divergence at mount-apply time: an insert lands at an index the real parent doesn't have (
IndexOutOfBoundsExceptioninaddViewAt), or a remove resolves a stale parent tag that's no longer the expectedViewGroup(IllegalStateExceptioninremoveViewAt).Expected behavior
A new transaction should only ever merge into the most recently queued pending transaction for its surface — the only one whose diffed-against tree state actually reflects everything already queued. That means the merge-target lookup should search from the back (
rbegin()/rend()) instead of the front (begin()/end()).Fix
Three-token change:
Environment
React Native 0.86.0, Android (Fabric), observed downstream in a fork carrying an additional local patch that makes
canMergeWithrefuse certain merges (aDelete↔Createtag-pairing guard), which is what surfaces multi-entrypendingTransactions_queues in practice. The underlying merge-target selection bug is present inschedulerDidFinishTransactionupstream regardless of that local patch — any code path that causescanMergeWithto refuse a merge (including future upstream guards) would trigger the same divergence.Reproducibility
No deterministic manual repro is provided here — the bug is timing/queue-state dependent. The mechanism is provable deterministically with a unit test against
MountingTransactiondirectly (construct 3 transactions, assert execution order), without needing device timing.