Skip to content

Fix Android Fabric mount crashes from merged mounting transactions (APP-7AR / APP-H7A) - #101057

Merged
tylerkaraszewski merged 10 commits into
Expensify:mainfrom
oqildev:fix/98791-fabric-mount-transaction-order
Sep 23, 2026
Merged

tylerkaraszewski merged 10 commits into
Expensify:mainfrom
oqildev:fix/98791-fabric-mount-transaction-order

Conversation

@oqildev

@oqildev oqildev commented Sep 14, 2026 •

Copy link
Copy Markdown
Contributor

Explanation of Change

On Android, FabricUIManagerBinding::schedulerDidFinishTransaction merges a finished mounting transaction into the one still pending for the same surface whenever commits outpace UI-thread mounting, and FabricMountingManager::executeMount then writes Delete mount items last and splits Updates into their own buckets. React Native's own comment on that block says the optimization "is incorrect when multiple transactions are merged together": a merged REMOVE -> DELETE | CREATE -> INSERT sequence is mounted as INSERT -> REMOVE -> DELETE, which destroys a view the shadow tree still considers mounted. Every index computed for that parent afterwards is stale, and that is what APP-7AR (addViewAt: failed to insert view, underlying IndexOutOfBoundsException: index=1 count=0) and APP-H7A (Unable to remove a view from a view that is not a ViewGroup) report.

This PR replaces patch +015 with react-native+0.86.0+015+fix-merged-transaction-mount-order.patch. +015 guarded the same hazard from the other side, by refusing the merge when the incoming transaction created a tag the pending one deleted. It only inspected Type::Create, so pairings such as Delete(tag) + Insert(same tag) still merged, and refusing a merge left a surface holding several pending transactions - a state upstream's merge-target lookup and its swap-then-mount drain do not expect.

The new patch sets maintainMutationOrder when the transaction is a merge of several commits (MountingTransaction::wasMerged()), so a merged batch is mounted in differ order and no mutation pairing can be reordered. Transactions that were never merged keep the delete-batching optimization exactly as it is, and differ-order mounting is already a supported mode - it is what disableMountItemReorderingAndroid turns on globally. With no refused merges, a surface holds one pending transaction again, which is upstream's own invariant.

The change is limited to Android mount ordering plus two small additions to the shared MountingTransaction, which iOS compiles but never calls wasMerged() on. There are no JS/TS changes.

Fixed Issues

$ #98791
PROPOSAL: #98791 (comment)

Tests

A. Android on-device

  1. In node_modules/react-native/ReactAndroid/src/main/jni/react/fabric/FabricMountingManager.cpp, right after auto revisionNumber = telemetry.getRevisionNumber();, add:
  if (transaction.wasMerged()) {
    LOG(ERROR) << "APP98791ORDER MERGED mutations=" << mutations.size()
               << " maintainOrder=" << (maintainMutationOrder ? 1 : 0);
  }
  1. Build Android with React Native compiled from source (no prebuilt artifact exists for this patch set): cd android && ./gradlew assembleDevelopmentDebug -PpatchedArtifacts.forceBuildFromSource=true.
  2. Install the app and run adb logcat | grep APP98791ORDER.
  3. Sign in and open Inbox.
  4. Open a chat, go back. Repeat with 2-3 different chats.
  5. Switch quickly between the bottom tabs (Inbox -> Spend -> Workspaces -> Inbox) several times.
  6. Open an expense report and close the RHP.
  7. Scroll the LHN list up and down quickly.
  8. Verify every APP98791ORDER MERGED line reports maintainOrder=1, so every merged batch is mounted in differ order.
  9. Verify the app does not crash and logcat has no addViewAt: failed to insert view or Unable to remove a view from a view that is not a ViewGroup.
  10. Repeat the same steps on main with the same logging added. The lines there report maintainOrder=0, which is the batch reordering this patch removes.
  11. Remove the logging.

B. Regression, all platforms

  1. Sign in and open Inbox.
  2. Open a chat, go back. Repeat with 2–3 different chats.
  3. Switch quickly between the bottom tabs (Inbox → Spend → Workspaces → Inbox) several times.
  4. Open an expense report and close the RHP.
  5. Scroll the LHN list up and down quickly.
  6. Verify navigation works, screens and lists render normally, and the app does not crash.

Note: on Android Native in development, React Navigation logs two dev-only warnings, The 'navigation' object hasn't been initialized yet at startup and The 'state' or 'screen' passed in the params couldn't be applied to the navigator with the screens: 'Inbox', 'Report' when returning to the Inbox tab. This PR has no JS changes, so both come from main and are unrelated.

  • Verify that no errors appear in the JS console

Offline tests

  1. Sign in and open Inbox.
  2. Turn off the network connection.
  3. Open a chat, go back, and switch quickly between the bottom tabs several times.
  4. Verify screens and lists render from cached data and the app does not crash.

QA Steps

The crash is a timing-dependent race in native mounting (roughly one event per affected user), so it cannot be triggered on demand on staging. QA should verify there is no regression in everyday navigation:

  1. Sign in and open Inbox.
  2. Open a chat, go back. Repeat with 2–3 different chats.
  3. Switch quickly between the bottom tabs (Inbox → Spend → Workspaces → Inbox) several times.
  4. Open an expense report and close the RHP.
  5. Scroll the LHN list up and down quickly.
  6. Verify navigation works, screens and lists render normally, and the app does not crash.

The fix itself should be confirmed after release by APP-7AR and APP-H7A event counts dropping on app versions that include this PR.

  • Verify that no errors appear in the JS console

PR Author Checklist

  • I linked the correct issue in the ### Fixed Issues section above
  • I wrote clear testing steps that cover the changes made in this PR
    • I added steps for local testing in the Tests section
    • I added steps for the expected offline behavior in the Offline steps section
    • I added steps for Staging and/or Production testing in the QA steps section
    • I added steps to cover failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
    • I tested this PR with a High Traffic account against the staging or production API to ensure there are no regressions (e.g. long loading states that impact usability).
  • I included screenshots or videos for tests on all platforms
  • I ran the tests on all platforms & verified they passed on:
    • Android: Native
    • Android: mWeb Chrome
    • iOS: Native
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • I verified there are no console errors (if there's a console error not related to the PR, report it or open an issue for it to be fixed)
  • I followed proper code patterns (see Reviewing the code)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I followed the guidelines as stated in the Review Guidelines
  • I tested other components that can be impacted by my changes (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar are working as expected)
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))
  • If new assets were added or existing ones were modified, I verified that:
    • The assets are optimized and compressed (for SVG files, run npm run compress-svg)
    • The assets load correctly across all supported platforms.
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • I added unit tests for any new feature or bug fix in this PR to help automatically prevent regressions in this user flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.

Screenshots/Videos

Android: Native
Android.native.-.fabric.crash_.mp4
Android: mWeb Chrome
mWeb.android.mp4
iOS: Native
ios-native_.mp4
iOS: mWeb Safari
mWeb.ios.mp4
MacOS: Chrome / Safari
web.chrome.mp4
web.safari.mp4

Patch +015 made canMergeWith able to refuse a merge, which broke two
invariants stock React Native relies on: that a surface has exactly one
pending transaction, and that a merged batch is safe to reorder.

- canMergeWith now also refuses an incoming Create/Insert of a tag the
  pending transaction deletes, and any mutation parented under such a tag
- the merge target is looked up from the back of the pending queue
- the queue is drained entry by entry instead of being swapped out, so a
  re-entrant commit cannot jump ahead of what is still pending
- merged transactions keep differ mutation order when mounting

Fixes the divergence behind Sentry APP-7AR and APP-H7A.
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ This PR is possibly changing native code and/or updating libraries, it may cause problems with HybridApp. Please check if any patch updates are required in the HybridApp repo and run an AdHoc build to verify that HybridApp will not break. Ask Contributor Plus for help if you are not sure how to handle this. ⚠️

@oqildev
oqildev marked this pull request as ready for review September 14, 2026 08:35
@oqildev
oqildev requested a review from a team as a code owner September 14, 2026 08:35
@melvin-bot
melvin-bot Bot requested review from linhvovan29546 and removed request for a team September 14, 2026 08:35
@melvin-bot

melvin-bot Bot commented Sep 14, 2026

Copy link
Copy Markdown

@linhvovan29546 Please copy/paste the Reviewer Checklist from here into a new comment on this PR and complete it. If you have the K2 extension, you can simply click: [this button]

@oqildev

oqildev commented Sep 14, 2026

Copy link
Copy Markdown
Contributor Author

@linhvovan29546 regarding the HybridApp warning above: this PR doesn't update any library versions. It adds one new React Native patch, patches/react-native/react-native+0.86.0+042+fix-merged-transaction-mount-order.patch, plus its details.md entry and a test harness under patches/react-native/tests/.

The patch touches FabricUIManagerBinding.cpp, FabricMountingManager.cpp and MountingTransaction.h/.cpp. All 42 App patches apply cleanly on current main, and I built Standalone Android (React Native compiled from source) and iOS locally without errors.

I don't have access to Mobile-Expensify/patches, so I can't check whether HybridApp has its own React Native patch touching those files or already using sequence number 042. Could you please check that and trigger an AdHoc build to verify HybridApp?

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 4d6cd8cdcb

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Taking entries off the pending queue one at a time let a concurrent drain
(JS thread vs UI thread) take a later transaction and mount it first.
Only one call now drains the queue; a call that finds a drain in progress,
re-entrant or on another thread, leaves its transaction queued for it.

The test now models MountItemDispatcher's FIFO and re-entrancy guard,
raises a re-entrant commit mid-batch, and adds a concurrent drain.
@linhvovan29546

Copy link
Copy Markdown
Contributor

@oqildev Honestly, I don't think we need to include the test in the patchs/react-native. Could you please remove it?

Requested in review: the patch does not need to ship its own test under
patches/react-native.
@oqildev

oqildev commented Sep 15, 2026

Copy link
Copy Markdown
Contributor Author

@linhvovan29546 Done, I removed patches/react-native/tests/ and its mention in details.md in c7c956a, and updated the PR description to match. The patch itself is unchanged.

@linhvovan29546

Copy link
Copy Markdown
Contributor

@codex review!

@linhvovan29546

Copy link
Copy Markdown
Contributor

@codex review

Comment thread patches/react-native/details.md Outdated
@linhvovan29546

Copy link
Copy Markdown
Contributor

@oqildev Codex isn’t responding for me. Could you please request a Codex review? Thanks!

@oqildev

oqildev commented Sep 17, 2026

Copy link
Copy Markdown
Contributor Author

@codex review

@linhvovan29546

Copy link
Copy Markdown
Contributor

@codex @MelvinBot Review

@MelvinBot

Copy link
Copy Markdown
Contributor

🤖 Code review — the diagnosis is right, but I'd push back on the shape of the fix. Two things:

1. Hunk 4 makes hunks 1–3 unnecessary

canMergeWith only exists because executeMount writes Delete mount items last. Its own doc comment says so: "Due to reordering of mutations during mount, the sequence of REMOVE -> DELETE | CREATE -> INSERT (2 transactions) may get changed to INSERT -> REMOVE -> DELETE".

Hunk 4 turns that reordering off for every merged transaction. Once it does, the divergence +015 guards against can't happen, so merges never need to be refused — and if merges are never refused, a surface can only ever hold one pending transaction, which is stock React Native's invariant. Hunks 2 (rbegin/rend) and 3 (drain serialization) exist only to handle the multi-pending-transaction state that +015 created.

So the minimal fix for APP-7AR and APP-H7A is hunk 4 plus reverting +015 — three lines instead of 202, with no new concurrency primitive.

The counter-argument would be that refusing a merge preserves mount-item batching for the refused transaction. But hunk 4 already forfeits batching for every transaction that did merge, and merging is the common case here — that's the premise of the bug. The batching you keep by refusing merges is marginal.

Suggestion: land hunk 4 on its own (hunk 1 is fine to keep as belt-and-braces), and either drop hunks 2–3 with +015, or explain in the PR why the queue machinery earns its risk.

2. Hunk 3 silently turns synchronous mounts into asynchronous ones

schedulerShouldRenderTransactions is called directly, on the calling thread, when mountSynchronously is true — the UI-thread synchronous-commit path (ShadowTree.cpp:505-509). With the new guard, if the JS thread is mid-drain, that call returns without mounting and the transaction lands later on the JS thread. The caller asked for "mounted before I return" and no longer gets it — for synchronous state updates (scroll offset, TextInput) that is a stale frame.

Note that cross-thread mounting was already mutually exclusive: FabricMountingManager::commitMutex_ is a std::recursive_mutex. The difference is that the second thread used to block and complete, not defer.

Suggestion: if you keep hunk 3, make it a lock rather than a skip flag, and only skip for same-thread re-entrancy. Store the draining thread id instead of a bool: same thread → return (correct, this is the re-entrant case you need to defer); different thread → wait for the drain, then drain your own. That preserves ordering and the synchronous contract, and it can't deadlock, since the drainer holds no lock while mounting.

Things I checked that are fine
  • Both feature flags default to false and App doesn't override them, so the patched else branch is live and disableMountItemReorderingAndroid() is off — hunk 4 is meaningful, not dead code.
  • No lost transactions from the early return: schedulerDidFinishTransaction always enqueues before schedulerShouldRenderTransactions runs, and the drainer's empty-check and flag-clear are in the same critical section as the push_back. Either the drainer sees the entry, or the flag is already clear.
  • deletedTags.contains(mutation.parentTag) can't false-positive: Create and Delete mutations carry parentTag = -1 (ShadowViewMutation.cpp:17, :27) and deletedTags only ever holds real tags.
  • merged_ survives the move and copy paths — MountingTransaction's copy ctor and move ops are = default, so the new member is carried along.
  • std::optional is already in scope in FabricUIManagerBinding.cpp.
  • Reverse-iterator use in hunk 2 is correct, and merging into the newest pending entry does preserve commit order within a surface.
  • erase(begin()) per iteration is O(n) on a vector, but n is tiny here.

Not verified: whether Mobile-Expensify/patches carries a conflicting RN patch over these same files. That still needs the AdHoc HybridApp build.


Next Steps: Reply with @MelvinBot <your feedback> to refine this review.


view run

Taking the pending queue one entry at a time under a boolean flag made a
synchronous commit on another thread return without mounting: the caller
asked for the transaction to be mounted before it returned, which is what
commitMutex_ used to give it.

The queue now records the draining thread. A re-entrant call on that
thread returns and lets the outer drain finish in commit order, while a
call on another thread waits for the drain and then drains itself.
@oqildev

oqildev commented Sep 19, 2026

Copy link
Copy Markdown
Contributor Author

🤖 Code review — the diagnosis is right, but I'd push back on the shape of the fix. Two things:

1. Hunk 4 makes hunks 1–3 unnecessary

canMergeWith only exists because executeMount writes Delete mount items last. Its own doc comment says so: "Due to reordering of mutations during mount, the sequence of REMOVE -> DELETE | CREATE -> INSERT (2 transactions) may get changed to INSERT -> REMOVE -> DELETE".

Hunk 4 turns that reordering off for every merged transaction. Once it does, the divergence +015 guards against can't happen, so merges never need to be refused — and if merges are never refused, a surface can only ever hold one pending transaction, which is stock React Native's invariant. Hunks 2 (rbegin/rend) and 3 (drain serialization) exist only to handle the multi-pending-transaction state that +015 created.

So the minimal fix for APP-7AR and APP-H7A is hunk 4 plus reverting +015 — three lines instead of 202, with no new concurrency primitive.

The counter-argument would be that refusing a merge preserves mount-item batching for the refused transaction. But hunk 4 already forfeits batching for every transaction that did merge, and merging is the common case here — that's the premise of the bug. The batching you keep by refusing merges is marginal.

Suggestion: land hunk 4 on its own (hunk 1 is fine to keep as belt-and-braces), and either drop hunks 2–3 with +015, or explain in the PR why the queue machinery earns its risk.

2. Hunk 3 silently turns synchronous mounts into asynchronous ones

schedulerShouldRenderTransactions is called directly, on the calling thread, when mountSynchronously is true — the UI-thread synchronous-commit path (ShadowTree.cpp:505-509). With the new guard, if the JS thread is mid-drain, that call returns without mounting and the transaction lands later on the JS thread. The caller asked for "mounted before I return" and no longer gets it — for synchronous state updates (scroll offset, TextInput) that is a stale frame.

Note that cross-thread mounting was already mutually exclusive: FabricMountingManager::commitMutex_ is a std::recursive_mutex. The difference is that the second thread used to block and complete, not defer.

Suggestion: if you keep hunk 3, make it a lock rather than a skip flag, and only skip for same-thread re-entrancy. Store the draining thread id instead of a bool: same thread → return (correct, this is the re-entrant case you need to defer); different thread → wait for the drain, then drain your own. That preserves ordering and the synchronous contract, and it can't deadlock, since the drainer holds no lock while mounting.

Things I checked that are fine

  • Both feature flags default to false and App doesn't override them, so the patched else branch is live and disableMountItemReorderingAndroid() is off — hunk 4 is meaningful, not dead code.
  • No lost transactions from the early return: schedulerDidFinishTransaction always enqueues before schedulerShouldRenderTransactions runs, and the drainer's empty-check and flag-clear are in the same critical section as the push_back. Either the drainer sees the entry, or the flag is already clear.
  • deletedTags.contains(mutation.parentTag) can't false-positive: Create and Delete mutations carry parentTag = -1 (ShadowViewMutation.cpp:17, :27) and deletedTags only ever holds real tags.
  • merged_ survives the move and copy paths — MountingTransaction's copy ctor and move ops are = default, so the new member is carried along.
  • std::optional is already in scope in FabricUIManagerBinding.cpp.
  • Reverse-iterator use in hunk 2 is correct, and merging into the newest pending entry does preserve commit order within a surface.
  • erase(begin()) per iteration is O(n) on a vector, but n is tiny here.

Not verified: whether Mobile-Expensify/patches carries a conflicting RN patch over these same files. That still needs the AdHoc HybridApp build.

Next Steps: Reply with @MelvinBot <your feedback> to refine this review.

view run

Point 2 is right, fixed in 13d6ebc: the queue now records the draining thread instead of a bool, so a re-entrant call returns while a call on another thread waits for the drain and then drains itself, keeping the synchronous contract commitMutex_ used to provide. The drainer is cleared and waiters woken even if executeMount throws.

On point 1, I agree hunk 4 subsumes the hazard +015 guards, but "keep hunk 1, drop hunks 2–3" doesn't hold: hunk 1 widens canMergeWith, so it makes refusals — and the multi-pending state — more likely, and it only works if the queue hunks stay.

So it's either reverting +015 and shipping hunk 4 alone, or this PR as it stands. I didn't pick the revert myself because +015 has 🛑 for its upstream link, E/App issue and PR in details.md, so I can't regression-test what it originally fixed — happy to do it if @linhvovan29546 and the assigned engineer prefer it.

…unt-transaction-order

# Conflicts:
#	patches/react-native/details.md
@linhvovan29546

Copy link
Copy Markdown
Contributor

@MelvinBot Review

@MelvinBot

Copy link
Copy Markdown
Contributor

🤖 Re-review of 13d6ebc. The thread-id drain is better than the skip flag, but it still doesn't deliver the synchronous contract — and I'd correct the premise you used to rule out the smaller fix.

1. +015 is only the guard, so the minimal fix is cheaper than you think

You said you didn't pick "revert +015, ship hunk 4" because +015 has 🛑 for its upstream link/issue/PR and you can't regression-test what it originally fixed.

+015 is three hunks and all three are canMergeWith: the && pendingTransaction->canMergeWith(...) conjunct, the function body, and its declaration. It fixes nothing else. What it "originally fixed" is the CREATE -> INSERT reordering hazard named in its own doc comment — which hunk 4 eliminates categorically, for every mutation pairing, not just the enumerated ones. There is no separate behavior to regression-test.

So option A is: hunk 4 + delete +015. No canMergeWith, no multi-pending queue, no rbegin, no drain serialization, no new concurrency primitive. Merging is back to stock RN's one-pending-transaction-per-surface invariant, and correctness comes from mounting in differ order rather than from enumerating unsafe pairs. disableMountItemReorderingAndroid exists precisely because differ-order mounting is a supported mode.

You're right that given +015 stays, hunks 1–3 hang together and can't be split. That's an argument for dropping +015, not for keeping the queue machinery.

2. The wait fixes who blocks, not who mounts

FabricUIManager.scheduleMountItem only dispatches inline when the caller is the UI thread. Off the UI thread, executeMount just appends to MountItemDispatcher and the items wait for the next tryDispatchMountItems.

Now trace a UI-thread synchronous commit while the JS thread is draining:

  1. UI thread enqueues T_ui in schedulerDidFinishTransaction, which always runs first, then calls schedulerShouldRenderTransactions and waits.
  2. The JS drainer's while (true) loop runs until the queue is empty, so it picks up T_ui and mounts it on the JS thread — no inline dispatch.
  3. UI thread wakes, becomes drainer, finds the queue empty, returns. Its views are not mounted; they land on the next frame.

The stale frame you were fixing is still there, one hop removed.

This interleaving exists upstream too, but only in the instant between the push and the swap — upstream takes a single snapshot and leaves anything queued during mounting for the next caller. The new loop drains until empty, so it absorbs every transaction queued across the whole mount, which is exactly the window a contended UI-thread commit lands in. Rare race → routine outcome.

Suggestion: option A deletes this problem rather than solving it. If you keep hunk 3, the drainer must not mount another thread's work: record the queueing thread per entry and stop the drain when the front entry was queued by a different thread after the drain started. That thread is guaranteed to arrive (schedulerDidFinishTransaction is always followed by schedulerShouldRenderTransactions), it takes over as drainer, and FIFO order still holds. Re-entrant entries carry the drainer's own thread id, so the outer call keeps mounting those.

Re-checked, still fine
  • The blocking itself isn't new: FabricMountingManager::executeMount takes commitMutex_, a std::recursive_mutex, for its whole body, so a second thread already blocked. The patch only widens the hold from one executeMount to a full drain.
  • std::optional<std::thread::id> == std::thread::id compiles and does what you want; the re-entrant return under the lock is fine, and nothing follows the if/else in schedulerShouldRenderTransactions, so nothing is skipped.
  • No lost wakeups: the empty-check, reset(), notify_all() and the push_back in schedulerDidFinishTransaction are all under pendingTransactionsMutex_.
  • The catch leaves the remaining entries queued rather than dropping them, which is strictly better than upstream's swap-then-throw.
  • pendingTransactions_ has no other readers or writers in the file, so no surface-teardown path races the drain.
  • 044 is free; 042 and 043 are taken, and the details.md entry matches the filename.
  • Previously verified and unchanged: feature flags both default false, deletedTags.contains(mutation.parentTag) can't false-positive, merged_ survives copy/move, rbegin/rend is correct.

Still not verified: whether Mobile-Expensify/patches carries a conflicting RN patch over these files. That needs the AdHoc HybridApp build.


Next Steps: Reply with @MelvinBot <your feedback> to refine this review.


view run

Patch +015 stopped the CREATE -> INSERT reordering hazard by refusing to
merge a transaction that created a tag the pending one deleted. It only
inspected Create, and a refused merge left a surface holding several
pending transactions, which upstream's merge-target lookup and its
swap-then-mount drain do not expect.

Setting maintainMutationOrder for merged transactions removes the hazard
for every mutation pairing, so +015 and the queue machinery built around
it are both deleted.
@oqildev

oqildev commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

Both points land, and I've taken option A: +015 is deleted and the patch is now hunk 4 alone — 65 lines across three files, no canMergeWith, no merge-target lookup, no drain serialization, no concurrency primitive. Merging is back to upstream's one-pending-transaction-per-surface invariant and correctness comes from mounting merged transactions in differ order.

You're right that +015 is only the guard: I checked, it is canMergeWith plus its call site and declaration and nothing else, so there is no separate behaviour to regression-test. That was the premise I used to rule the revert out, and it was wrong.

You're also right about the drain. The loop runs until the queue is empty, so a JS-thread drainer mounts the UI thread's transaction off the UI thread, and scheduleMountItem only dispatches inline when the caller is the UI thread — the frame is still stale, one hop later. Deleting the queue machinery removes that path rather than adding a per-entry thread check to work around it.

The Android evidence on this PR asserted on canMergeWith, which no longer exists, so I'm re-recording it against merged transactions mounting in differ order.

@linhvovan29546

Copy link
Copy Markdown
Contributor

@MelvinBot Review

@MelvinBot

Copy link
Copy Markdown
Contributor

🤖 Re-review of 81d4eb8. This is the right shape now — 4 hunks, 3 files, no canMergeWith, no pending-transaction queue, no concurrency primitive. I verified the patch applies exactly and that nothing else in the patch set depended on +015. Two follow-ups, neither blocking.

1. wasMerged()'s doc comment claims more than the flag tracks

The comment says "Returns true when this transaction is the result of merging two or more differ-produced transactions". merged_ is set only in mergeWith, and mergeWith has exactly one caller — FabricUIManagerBinding.cpp:647. There is a second way to get a cross-commit mutation list, and it doesn't go through mergeWith:

LayoutAnimationKeyFrameManager::pullTransaction appends mutations deferred from earlier commits onto the current commit's list (animationMutationsForFrame → mutations.insert(mutations.end(), …), LayoutAnimationKeyFrameManager.cpp:984-1001) and returns a freshly constructed MountingTransaction (:1046) — so merged_ == false. It is registered as a mounting-override delegate on every Android surface (FabricUIManagerBinding.cpp:152-153, 212-213, 386-387).

Dormant, not live: it only appends when shouldAnimateFrame() is true, which needs LayoutAnimation.configureNext / UIManager.configureNextLayoutAnimation, and I found no call to either in src (App uses Reanimated, a separate mechanism). The deferred mutations are exiting-view Remove/Delete, which is the pairing that hurts.

Suggestion: no code change — just say what the flag actually means, e.g. "Returns true when mergeWith has concatenated another transaction into this one." Otherwise the next reader assumes every cross-commit transaction is covered.

2. The gate is broader than the FIXME it cites — worth recording why

The FIXME is attached only to the deletes-last write block (FabricMountingManager.cpp:974-987). Gating the whole maintainMutationOrder flag also un-buckets the six Update categories (:919-972).

That extra scope is justified — bucketing puts Updates before all Deletes, so a merged Update(X)@N + Create(X)@N+1 pair applies commit N's props to the view created in N+1, which is a second real hazard the FIXME doesn't name. But it isn't free, and details.md currently only argues the delete case:

  • Per instruction run instead of per batch: one extra int-buffer preamble, plus arrayOf("numInstructions", …) on the Java side (IntBufferBatchMountItem.kt:95-103, not gated on tracing). An insert-heavy merged batch has ~7 type transitions per node, so ~7 small allocations per node instead of ~7 per batch, and up to ~+29% on the int array. Still one batch, one int array, one JNI call, same mount-item count — and cheaper than the two-batch path +015 produced when it refused a merge.
  • Bucketing guaranteed all props precede all layouts batch-wide; mutation order guarantees it only per view (SurfaceMountingManager.kt:824-828 asks for the batch-wide form). Low risk — Yoga passes exact dimensions — and it's already the global behavior under disableMountItemReorderingAndroid = true.
  • Merging is routine, not rare: RuntimeScheduler_Modern::scheduleRenderingUpdate only queues, draining at end of tick, so any two commits for the same surface in one tick merge. So this path is common, which is the point, but it means the cost above isn't a rare-path cost. There's no telemetry for it either — mergeWith carries // TODO T186641819: Telemetry for merged transactions is not supported. A commitNumber gap between FABRIC_BATCH_EXECUTION_START markers is the only proxy.

Suggestion: add a line to the details.md entry covering the Update-bucketing half, so nobody later "narrows" this to deletes only and reintroduces the props-hoisting case.

Verified fine

Patch mechanics

  • +044 applies exactly. Reversing +015 from node_modules yields blobs d7dd1bc / 7841b5a / 66436ac — identical to +044's declared pre-images — and git apply produces e24c746 / 47dcb68 / bca8b6e, identical to its post-images. Zero offset, zero fuzz.
  • Nothing else depended on +015. Only +027+fix-pressability-new-arch also touches FabricUIManagerBinding.cpp, and +015's single hunk there was line-count-neutral (7→7), so removing it shifts nothing; with +015 reversed the file hashes to 3428553, exactly +027's own post-image. MountingTransaction.cpp/.h are now touched by +044 alone. +044's FabricMountingManager.cpp pre-image 66436ac is +027's post-image, so the stack is ordered correctly.
  • Zero canMergeWith references left in the tracked tree. 044 is unique, the details.md link text and href both match the filename exactly, the +015 heading is gone (+014 → +016), and headings stay ascending so appending at the end is correct. All 4 hunk @@ counts are self-consistent; no CR bytes, single trailing newline.
  • Operational note: patch-package doesn't rebase when the patch list changes, so every warm node_modules will hit Error: The patches for react-native have changed once. scripts/applyPatches.ts parses that, rm -rfs node_modules/react-native and reinstalls, so it self-heals — expect one slow install, not a failure. Inherent to removing any mid-sequence patch.

C++ semantics

  • merged_ survives every copy/move path: MountingTransaction's copy ctor is = default (.h:46), move ctor/assignment = default (:52-53), copy assignment deleted. No hand-written member-wise copy to drop it. The only mem-init-list (.cpp:19-22) omits merged_, which is exactly why the bool merged_{false}; NSDMI is needed — it's there.
  • The merged object is the mounted object. push_back at FabricUIManagerBinding.cpp:649, in-place mergeWith at :647, pendingTransactions_.swap at :695, executeMount(transaction) by const-ref at :699. No copy, no slice (final class). merged_ is written under pendingTransactionsMutex_ and read after the same mutex edge as mutations_, so no new race.
  • Flag can't leak: the swap drains pendingTransactions_ and the local vector destroys every merged transaction after mounting.
  • Skipped-revision diffs are not a gap, which was my main worry about relying on mergeWith. MountingCoordinator::push keeps only the newest revision, so one pullTransaction can diff N-3 against N — but it's a single calculateShadowViewMutations call, a tag gets at most one of Create/Delete per diff (Differentiator.cpp:832-834, :1267, :1296), and the differ already emits delete-before-create (:1611-1637). Deletes-last is sound for it, so wasMerged() == false is the right answer there.
  • Mechanism confirmed: flatten/unflatten keeps the tag stable while toggling concrete-view-ness (Differentiator.cpp:839-841, :856-857), so DELETE X … CREATE X across two commits is reachable; reordered to CREATE X … DELETE X, createView early-returns on the surviving viewState (SurfaceMountingManager.kt:560-563) and the trailing deleteView drops a still-mounted view — the APP-7AR / APP-H7A signatures. RN's own soft exception for this reads "This indicates a bug in the Differ (specifically instruction ordering)" (SurfaceMountingManager.kt:422).
  • maintainMutationOrder = true is a supported mode, not dead code: disableMountItemReorderingAndroid defaults false (ReactNativeFeatureFlagsDefaults.h:54-56) and isn't overridden anywhere in App, and upstream is rolling it on — SurfaceMountingManager.kt:847-848 has // TODO T212247085: Make this non-nullable again after rolling out disableMountItemReorderingAndroid. enableAccumulatedUpdatesInRawPropsAndroid also defaults false, so the merging branch is live. Both preconditions for the bug are live in the App build.
  • Nothing downstream assumes deletes-last or bucketed Updates. IntBufferBatchMountItem.execute is a sequential interpreter; MountItemDispatcher only orders categories of mount item, never inside a batch. allocatedViewTags bookkeeping is done inline per mutation and is ungated, so it now agrees with the emitted order instead of disagreeing.

Existing App patches

  • No patch depends on the old ordering. +003, +013, +027, +032 are iOS/JS only. +016+fix-crash-when-deleting-expense (endViewTransition before removeView) and +038+log-soft-exception-if-viewState-not-found are both error-recovery paths for exactly this divergence — +038 is the same workaround upstream tagged with the disableMountItemReorderingAndroid TODO. Both should fire less often after this.
  • View recycling is moot and the premise inverts: enableViewRecycling() defaults false and isn't overridden. If it were on, deletes-last makes same-batch reuse structurally impossible, so differ order would raise the hit rate.
  • Bonus from deleting +015: its refusal left two entries for one surface, and the merge-target lookup is std::find_if, which returns the first match — so a third transaction merged into the older entry, out of order. That path is gone.

Not verified: whether Mobile-Expensify/patches carries a conflicting RN patch over these files, or a HybridApp-side ReactNativeFeatureFlagsProvider that flips these flags — Mobile-Expensify isn't checked out here. Still needs the AdHoc HybridApp build. Also unmeasured: the actual merge frequency and the allocation delta above; both are static reasoning only.

Nothing here blocks. Once the Android evidence is re-recorded against merged transactions mounting in differ order and the AdHoc HybridApp build is clean, I'd land this.


Next Steps: Reply with @MelvinBot <your feedback> to refine this review.


view run

@linhvovan29546

Copy link
Copy Markdown
Contributor

The change is risky, so I’m checking and reviewing it carefully to make sure it doesn’t cause any other issues.

@oqildev

oqildev commented Sep 22, 2026

Copy link
Copy Markdown
Contributor Author

Both taken in 964acc4, documentation only.

  1. Reworded the comment to say what the flag actually tracks — mergeWith concatenating another transaction into this one — and to name the update bucketing alongside deletes-last. Your read on LayoutAnimationKeyFrameManager::pullTransaction checks out: it appends the deferred mutations onto the current list and returns a freshly constructed MountingTransaction, so merged_ is false there, and App's only LayoutAnimation references are Reanimated's builders, not configureNext.

  2. Added a details.md line for the update half: bucketing hoists an Update(X) from one commit ahead of a Create(X) from the next, which the FIXME doesn't name, so narrowing this patch to deletes would reintroduce that case. The preamble-per-run cost is recorded with it.

Remaining, as you say: the Android evidence re-recorded against merged transactions mounting in differ order, and the AdHoc HybridApp build for Mobile-Expensify/patches, which I can't see from here.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Since we removed the +015 patch, I think we should update this patch name to +015.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done in 3234cd5 — the patch is now react-native+0.86.0+015+fix-merged-transaction-mount-order.patch, and its details.md entry moved up between +014 and +016.

One thing the rename changes: +015 is applied before +027, which also touches FabricMountingManager.cpp, so I regenerated the patch against the pre-+027 tree. The hunk there moves from line 581 to 580 and the pre-image hashes change with it. I checked both orders end up byte-identical: applying +015 then +027 to a pristine 0.86.0 tree produces exactly the same three files as the old +027 then +044 order, and +027 still applies cleanly on top.

@linhvovan29546

Copy link
Copy Markdown
Contributor

@oqildev Could you please merge main again? Thanks

@oqildev

oqildev commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor Author

@linhvovan29546 Merged main in 0d69d14 — no conflicts this time, and patches/react-native was untouched upstream, so the patch applies unchanged. validatePatches is green and the PR is still the same three files.

The old +015 is deleted by this PR, so its number is free and keeping
+044 would leave a gap. Regenerated against the pre-+027 tree, since
+015 is applied before it: the hunk in FabricMountingManager.cpp moves
from line 581 to 580 and the pre-image hashes change accordingly.
Applying +015 then +027 produces byte-identical sources to the previous
+027 then +044 order.
@linhvovan29546

linhvovan29546 commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Reviewer Checklist

  • I have verified the author checklist is complete (all boxes are checked off).
  • I verified the correct issue is linked in the ### Fixed Issues section above
  • I verified testing steps are clear and they cover the changes made in this PR
    • I verified the steps for local testing are in the Tests section
    • I verified the steps for Staging and/or Production testing are in the QA steps section
    • I verified the steps cover any possible failure scenarios (i.e. verify an input displays the correct error message if the entered data is not correct)
    • I turned off my network connection and tested it while offline to ensure it matches the expected behavior (i.e. verify the default avatar icon is displayed if app is offline)
  • I checked that screenshots or videos are included for tests on all platforms
  • I included screenshots or videos for tests on all platforms
  • I verified that the composer does not automatically focus or open the keyboard on mobile unless explicitly intended. This includes checking that returning the app from the background does not unexpectedly open the keyboard.
  • I verified tests pass on all platforms & I tested again on:
    • Android: HybridApp
    • Android: mWeb Chrome
    • iOS: HybridApp
    • iOS: mWeb Safari
    • MacOS: Chrome / Safari
  • If there are any errors in the console that are unrelated to this PR, I either fixed them (preferred) or linked to where I reported them in Slack
  • I verified proper code patterns were followed (see Reviewing the code)
    • I verified that comments were added to code that is not self explanatory
    • I verified that any new or modified comments were clear, correct English, and explained "why" the code was doing something instead of only explaining "what" the code was doing.
    • I verified any copy / text that was added to the app is grammatically correct in English. It adheres to proper capitalization guidelines (note: only the first word of header/labels should be capitalized), and is either coming verbatim from figma or has been approved by marketing (in order to get marketing approval, ask the Bug Zero team member to add the Waiting for copy label to the issue)
  • If a new code pattern is added I verified it was agreed to be used by multiple Expensify engineers
  • I verified that this PR follows the guidelines as stated in the Review Guidelines
  • I verified other components that can be impacted by these changes have been tested, and I retested again (i.e. if the PR modifies a shared library or component like Avatar, I verified the components using Avatar have been tested & I retested again)
  • If a new component is created I verified that:
    • A similar component doesn't exist in the codebase
    • All props are defined accurately
    • The component has a clear name that is non-ambiguous and the purpose of the component can be inferred from the name alone
    • The only data being stored in the state is data necessary for rendering and nothing else
    • The component has the minimum amount of code necessary for its purpose, and it is broken down into smaller components in order to separate concerns and functions
  • If a new CSS style is added I verified that:
    • A similar style doesn't already exist
    • The style can't be created with an existing StyleUtils function (i.e. StyleUtils.getBackgroundAndBorderStyle(theme.componentBG)
  • If the PR modifies code that runs when editing or sending messages, I tested and verified there is no unexpected behavior for all supported markdown - URLs, single line code, code blocks, quotes, headings, bold, strikethrough, and italic.
  • If the PR modifies a generic component, I tested and verified that those changes do not break usages of that component in the rest of the App (i.e. if a shared library or component like Avatar is modified, I verified that Avatar is working as expected in all cases)
  • If the PR modifies a component related to any of the existing Storybook stories, I tested and verified all stories for that component are still working as expected.
  • If the PR modifies a component or page that can be accessed by a direct deeplink, I verified that the code functions as expected when the deeplink is used - from a logged in and logged out account.
  • If the PR modifies the UI (e.g. new buttons, new UI components, changing the padding/spacing/sizing, moving components, etc) or modifies the form input styles:
    • I verified that all the inputs inside a form are aligned with each other.
    • I added Design label and/or tagged @Expensify/design so the design team can review the changes.
  • For any bug fix or new feature in this PR, I verified that sufficient unit tests are included to prevent regressions in this flow.
  • If the main branch was merged into this PR after a review, I tested again and verified the outcome was still expected according to the Test steps.
  • I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR.

Screenshots/Videos

Android: HybridApp

Uploading Screen Recording 2026-09-23 at 19.05.54.mov…

Android: mWeb Chrome

The change only for android/iOS

iOS: HybridApp
Screen.Recording.2026-09-23.at.19.51.12.mov
iOS: mWeb Safari

The change only for android/iOS

MacOS: Chrome / Safari

The change only for android/iOS

@tylerkaraszewski
tylerkaraszewski merged commit 430f8f7 into Expensify:main Sep 23, 2026
26 of 29 checks passed
@OSBotify

Copy link
Copy Markdown
Contributor

✋ This PR was not deployed to staging yet because QA is ongoing. It will be automatically deployed to staging after the next production release.

@github-actions

Copy link
Copy Markdown
Contributor

🚧 tylerkaraszewski has triggered a test Expensify/App build. You can view the workflow run here.

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to staging by https://github.com/tylerkaraszewski in version: 9.4.93-0 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to production by https://github.com/lakchote in version: 9.4.93-3 🚀

platform result
🕸 web 🕸 success ✅
🤖 android 🤖 success ✅
🍎 iOS 🍎 success ✅

Bundle Size Analysis (Sentry):

The doc comment implied every cross-commit mutation list is covered.
LayoutAnimationKeyFrameManager::pullTransaction appends mutations
deferred from earlier commits and returns a freshly constructed
transaction, so merged_ is false there; it is dormant in App, which uses
Reanimated rather than LayoutAnimation.configureNext.

details.md now also records the Update half: bucketing hoists an
Update(X) from one commit ahead of a Create(X) from the next, so
narrowing this to deletes would reintroduce that case.
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.

5 participants