perf: defer the split-save API.write behind the destination layout - #98012
perf: defer the split-save API.write behind the destination layout#98012Abdukhamid000 wants to merge 18 commits into
Conversation
updateSplitTransactions is the only IOU write-then-navigate flow that never routed its API.write through deferOrExecuteWrite. API.write applies optimisticData synchronously, so the destination screen re-renders from the transaction/report/report-action collections the write touches while the press is still trying to paint. Collect the three apiWrite calls into one closure and hand it to deferOrExecuteWrite, gated on isFromSplitExpensesFlow so useDeleteTransactions and other callers are unaffected. Reserve the matching channel on each of the four exit branches before navigating (SEARCH on the Search branch, DISMISS_MODAL with the destination report ID on the report branches), and move updateSplitTransactions after the navigation call on the two branches that ran it first. Every navigation decision is computed from params.allTransactionsList before any write, so deferring cannot change where the user lands. onyxData is unchanged.
…ions REPORT_NAME_VALUE_PAIRS, SNAPSHOT and PERSONAL_DETAILS_LIST were each read by useOnyx and then used in exactly one place - forwarded to updateSplitTransactionsFromSplitExpensesFlow. Nothing rendered from them, so every unrelated write to those collections re-rendered the page. Read them at save time through the existing getAllReportNameValuePairs, getAllSnapshots and getAllPersonalDetails getters instead. All three are already backed by module-level caches in actions/IOU/index.ts, so this adds no new Onyx subscription, and none of them carries a deprecation tag. allTransactionsList and POLICY_TAGS are deliberately left alone: the former would lose Search-snapshot-only transactions, and getPolicyTags is slated for removal in Expensify#72721.
The expense-report transaction filter ran twice per press over the whole transaction collection - once for areAllExpenseReportTransactionsSplitChildren and again just to take .length for the last-transaction check. Hoist it to a single scan. The two consumers disagree on how a missing expenseReportID should behave, so the guard moves onto the derived value to keep both behaviours byte-identical rather than onto the scan.
Three tests in SplitTest.ts for the new behaviour: - saving from the Search page reserves the SEARCH channel and routes the write through deferOrExecuteWrite with shouldDeferForSearch: true - saving from a report defers without ever touching the SEARCH channel - a direct updateSplitTransactions call outside the split-expenses flow is not deferred, since there is no navigation to hide behind SplitSelfDMTest.ts needed the deferredLayoutWrite mock SplitTest.ts already uses. Without it the selfDM branch now parks its write on the DISMISS_MODAL channel and no destination screen mounts in a test to flush it, so three assertions on optimistic data failed. The mock runs the write inline; the deferral timing itself is covered by the tests above rather than mocked away.
|
Hi! I'm really happy to see you want to use my solution. That being said, please use |
af39069 to
e997be4
Compare
|
All contributors have signed the CLA ✍️ ✅ |
@JakubKorytko asked for writeWhenReady on the PR: deferOrExecuteWrite is being migrated and removed in the coming days, so building on it now would only create a conflict. Credit to @annaweber830, whose proposal identified this primitive. The barrier is route agnostic, which simplifies the change considerably: - writeWhenReady(command, params, onyxData, createTransitionBarrier('navigation')) replaces the closure plus deferOrExecuteWrite - all four reserveDeferredWriteChannel calls are gone; a barrier needs no channel reserved ahead of navigating, so the Search branch no longer needs special casing - the two branches that were reordered are back to committing before they navigate, which is also what the barrier wants: it waits for an upcoming navigation transition, so it has to be registered before that navigation Still gated on isFromSplitExpensesFlow. Outside that flow there is no transition to wait on and the default barrier would stall the write for roughly 2s, so those callers keep writing immediately. Tests assert the barrier-gated path on both the report and Search branches, and that a direct updateSplitTransactions call is not deferred. Both suites mock writeWhenReady to run inline, since no screen transition happens in a test and the barrier would otherwise hold the write until its safety timeout.
|
@codex review |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 7220b84414
ℹ️ 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".
Resolved one conflict in SplitTransactionUpdate.ts: main added the CurrencyListActionsContextType import and this branch changed the @libs/API import to pull in createTransitionBarrier/writeWhenReady. Kept both. main also made getCurrencyDecimals a required param on BuildOptimisticIOUReportActionParams and UpdateSplitTransactionsParams, so the split-save tests added here now pass getCurrencyDecimalsLocal, matching the convention already used elsewhere in SplitTest.ts.
|
I have read the CLA Document and I hereby sign the CLA |
|
recheck |
|
@ikevin127 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] |
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 260fe6631e
ℹ️ 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".
JmillsExpensify
left a comment
There was a problem hiding this comment.
No product review required.
rlinoz
left a comment
There was a problem hiding this comment.
Looking good, but I think we can improve the design here.
Also, can you please sign your commits 🙇
| * | ||
| * Opting in is suite-wide, not per-flow: `jest.mock('@libs/API/writeWhenReady')` un-defers every caller in the | ||
| * suite. Today `SplitTransactionUpdate.ts` is the only production caller, but `deferOrExecuteWrite` is being | ||
| * migrated onto this module - once those flows land, this mock will silently un-defer them and no test will fail. |
There was a problem hiding this comment.
| * | |
| * Opting in is suite-wide, not per-flow: `jest.mock('@libs/API/writeWhenReady')` un-defers every caller in the | |
| * suite. Today `SplitTransactionUpdate.ts` is the only production caller, but `deferOrExecuteWrite` is being | |
| * migrated onto this module - once those flows land, this mock will silently un-defer them and no test will fail. |
| allReportNameValuePairsList: getAllReportNameValuePairs(), | ||
| allSnapshots: getAllSnapshots(), |
There was a problem hiding this comment.
Unfortunately I don't think we can use this, since we are in the process of deprecating Onyx.connect which this functions depend on (they should probably be deprecated or removed).
The real solution will depend on each case, but it is usually to call useOnyx with some selector.
For instance I think getAllReportNameValuePairs() is not needed, in the split flow it is only used here
reportID which we should be able to derive for the selector.
There was a problem hiding this comment.
Agreed, and done locally — it goes out with the signed force-push, so it isn't on the branch yet.
SplitExpensePage already reads whole collections through useOnyx (:111, :112, plus useAllTransactions()), so the three getters follow the same pattern:
-import {getAllPersonalDetails, getAllReportNameValuePairs, getAllSnapshots} from '@libs/actions/IOU';
+ const [allReportNameValuePairs] = useOnyx(ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS);
+ const [allSnapshots] = useOnyx(ONYXKEYS.COLLECTION.SNAPSHOT);
+ const [personalDetails] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST);getAllReportNameValuePairs() / getAllSnapshots() / getAllPersonalDetails() are gone from this page entirely, so nothing here depends on Onyx.connect any more. Net +6/-4; lint, typecheck and React Compiler compliance pass, and the three split suites (SplitTest, SplitReportTotalsTest, TransactionTest) are green at 170 tests.
On narrowing them to per-key selectors — two of the three don't reduce cleanly, so I'd rather raise it than quietly ship something that breaks.
allReportNameValuePairsList has a second consumer. Besides SplitTransactionUpdate.ts:399 keyed on expenseReport?.reportID, there is :1375:
const reportNameValuePairs = allReportNameValuePairsList?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${splitTransaction?.reportID}`];That runs per split transaction inside the undeleted-transactions loop, and splitTransaction is looked up out of allTransactionsList one line earlier at :1370 — so those reportIDs aren't known at render time. A selector narrowed to expenseReport.reportID would leave isArchivedReport(...) at :1378 reading undefined for every split.
allSnapshots is scanned wholesale on purpose. :1459, :1521 and :1727 each iterate Object.entries(allSnapshots), per the comment at :1448: "We scan allSnapshots instead of relying on currentSearchHash because the user may navigate…". There is no single key to select.
getAllPersonalDetails() reads ONYXKEYS.PERSONAL_DETAILS_LIST, a single key, so that one is a straight swap either way.
So: is whole-collection useOnyx acceptable here, matching allReports/allReportActions already in this file? Or would you rather have the selectors, with those two call sites reworked to feed them? Happy either way — the second is a meaningfully larger change to SplitTransactionUpdate, so I'd rather confirm the shape before writing it.
@Abdukhamid000 For this you need to follow the guide on getting your commits signed here. This means that you will probably have to force-push again since all commits on a PR must be Verified (signed), otherwise PR cannot be merged.
Force-pushing again will be required in this case as to avoid closing and reopening the PR and losing all discussion / context, but note it for the future to not repeat - unless it would require closing / reopening PR and losing significant context (like this case). |
…x.connect getters
6209581 to
ad21c9d
Compare
|
@rlinoz — signed commits are pushed. All 12 commits on the branch now show as Verified, so Two things: 1. The workflows need approving again. The push reset them — all 15 runs, including 2. Both of your review comments are addressed. The I left one question open in that thread: whether you want them narrowed to per-key selectors. Two of the three don't reduce cleanly — Other than those two changes, the history rewrite was signature-only — the diff is byte-identical to what you reviewed. |
Codecov Report✅ Changes either increased or maintained existing code coverage, great job!
|
|
Please read our AI_ETIQUETTE, the whole conversation in this PR is really bad to follow. With that said, if we can't selectors to onyx this makes this change probably not effective, no? |
|
You're right. Whole-collection Selectors don't fit: This PR is now just the deferred write. Sorry for the noise above. |
…2-defer-split-save # Conflicts: # tests/actions/IOUTest/SplitTest.ts
| // API.write() applies optimisticData synchronously, so the destination screen re-renders from the | ||
| // transaction, report and report-action collections this write touches while the press is still | ||
| // trying to paint. Inside the split-expenses flow there is always a navigation to hide behind, so | ||
| // defer the write until that screen transition has finished. Outside it there is no transition to | ||
| // wait on, and a default barrier would stall the write for ~2s, so write immediately instead. |
There was a problem hiding this comment.
One last thing, can we simplify this comment please?
|
@ikevin127 can you complete the checklist please? |
|
@JakubKorytko Can you please review this PR as well? |
|
Yeah, I took a look earlier but will do a proper review today |
| /** | ||
| * Re-exported from the real module so the mock matches its full surface. A suite that mocks this module and | ||
| * also feeds this constant into timer control (as `tests/unit/APIWriteWhenReadyTest.ts` does) would otherwise | ||
| * read `undefined` and fail somewhere that points nowhere near this file. | ||
| */ |
There was a problem hiding this comment.
| /** | |
| * Re-exported from the real module so the mock matches its full surface. A suite that mocks this module and | |
| * also feeds this constant into timer control (as `tests/unit/APIWriteWhenReadyTest.ts` does) would otherwise | |
| * read `undefined` and fail somewhere that points nowhere near this file. | |
| */ |
seems kinda obvious as we do it for all mocks, a one short sentence would be enough or just remove
JakubKorytko
left a comment
There was a problem hiding this comment.
a lot better now, one comment not super important though
JakubKorytko
left a comment
There was a problem hiding this comment.
won't harass you anymore, thanks for addressing the review
|
bump @ikevin127 on the checklist |
ikevin127
left a comment
There was a problem hiding this comment.
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 Issuessection 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
Testssection - I verified the steps for Staging and/or Production testing are in the
QA stepssection - 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 verified the steps for local testing are in the
- 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 usingAvatarhave 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
Avataris modified, I verified thatAvataris 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
Designlabel and/or tagged@Expensify/designso 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
mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps. - I have checked off every checkbox in the PR reviewer checklist, including those that don't apply to this PR.
Screenshots/Videos
Android: HybridApp
android.mp4
iOS: HybridApp
multi-ios.mov
iOS: mWeb Safari
| Single | Multi |
|---|---|
single-ios-mweb.mov |
multi-ios-mweb.mov |
| Multi (reduced padding) |
|---|
ios-mweb-padding-2.mov |
MacOS: Chrome / Safari
| Single | Multi |
|---|---|
single.mov |
multi.mov |
Explanation of Change
updateSplitTransactionswas the only IOU write-then-navigate flow that never deferred itsAPI.write.API.writeappliesoptimisticDatasynchronously, so the destination screen re-renders from the transaction, report and report-action collections the write touches while the Save press is still trying to paint. This flow reached forrequestAnimationFrameon two of its four exit branches, which fires before the paint.Three changes:
API.writeWhenReady(command, params, onyxData, createTransitionBarrier('navigation')), gated onisFromSplitExpensesFlow. Per JakubKorytko on this PR,deferOrExecuteWriteis being migrated out and removed shortly, so this useswriteWhenReadyinstead - credit to annaweber830, whose proposal identified that primitive. The barrier is route agnostic, so no channel has to be reserved per exit branch and the Search branch needs no special casing. Outside the split-expenses flow there is no transition to wait on and the default barrier would stall the write ~2s, so those callers (e.g.useDeleteTransactions) keep writing immediately.SplitExpensePage(folding in yusufdeveloper2903's proposal).REPORT_NAME_VALUE_PAIRS,SNAPSHOTandPERSONAL_DETAILS_LISTwere each read withuseOnyxand then used in exactly one place - forwarded to the save action. Nothing rendered from them, so every unrelated write to those collections re-rendered the page. They now go through the existinggetAllReportNameValuePairs,getAllSnapshotsandgetAllPersonalDetailsgetters, all already backed by module-level caches inactions/IOU/index.ts, so no new Onyx subscription is added. Correction to an earlier version of this body: those getters do sit on plainOnyx.connect(src/libs/actions/IOU/index.ts:11,74), whose JSDoc readsThis method will be deprecated soon. Please use Onyx.connectWithoutView() instead.This PR adds no new subscription of either kind, but it is not accurate to say the path carries no deprecation tag - it reuses existing subscriptions that do. Migrating those getters toconnectWithoutViewis out of scope here and belongs in its own PR.allTransactionsListonce instead of twice per press (folding in dilshodmackbook-sketch's dedup). The two consumers disagree on how a missingexpenseReportIDshould behave, so the guard moved onto the derived value rather than onto the scan, keeping both behaviours byte-identical.Per the review notes,
allTransactionsListandPOLICY_TAGSare deliberately left alone - the former would lose Search-snapshot-only transactions, andgetPolicyTagsis slated for removal in #72721. No hash-scopedSNAPSHOTselector and nogetAllTransactionViolations.Every navigation decision is computed from
params.allTransactionsListbefore any write, so deferring cannot change where the user lands. The emittedonyxDatais unchanged.Durability trade-off (answering ikevin127 and the Codex P2 bot, same concern from two angles) - resolved by @JakubKorytko. The cost is real and stated plainly:
writeWhenReadydoes not reachprepareRequestuntil the barrier releases, so there is a window - bounded bySAFETY_TIMEOUT_MS, currently5 * CONST.MAX_TRANSITION_DURATION_MS= 5000 ms, and in practice the length of one screen transition - in which a browser reload or a native hard kill drops the split with no error and no queued request.apiWritepersisted synchronously and had no such window.I raised this against
writeWhenReady's own caveat (critical writes (such as ones that move money) should not be deferred) and offered to revert to a synchronousAPI.write. Jakub, who wrote both the primitive and that caveat, ruled that the wording is the problem rather than this PR:SendMoneyalready defers its write today viadeferOrExecuteWritewith the same risk shape, so "moves money" was never the line the codebase actually draws. The operative test is whether losing the write leaves something unrecoverable or hard to reconcile versus merely annoying to redo - a dropped split is the latter. He is tightening the wording inwriteWhenReady.tsso this does not resurface on the next PR, and is fine keeping the deferral here.Why it stands: the window is the transition the user is already watching, they cannot interact during it, and deferring a money-moving write is already the established pattern in this directory rather than a new precedent - on
maintodaySendMoney.ts:557(SEND_MONEY_ELSEWHERE/SEND_MONEY_WITH_WALLET),Split.ts:370and:488,PerDiem.ts:1119and:1217, andSendInvoice.ts:874all defer viadeferOrExecuteWrite. There is aflushOnBackgroundbest-effort path for backgrounding, though the docs are explicit that it is not guaranteed.Open item: the INP benefit is still not demonstrated, and my earlier attempt to size it measured the wrong thing twice over. The first measurements were withdrawn because the profiler recorded
pointerdownrather than the interaction's longest event, under-reporting INP by roughly 8-10x. The replacement figure I quoted -API.writeat 1.0 ms of a 6-17 ms interaction - is also not the cost this defers: as Jakub points out,write()itself is the cheap part, and what the barrier actually holds back is the optimistic Onyx update and the re-render it triggers, which is the work competing with the transition. That is the number worth having and I do not have it yet. The barrier measurement below confirms the mechanism engages; it does not show that INP improved. Sizing the optimistic-update and re-render cost is the remaining work, and it is not a blocker for this PR per the ruling above.Barrier behaviour on the Search branch - confirmed. ikevin127 raised that the barrier waits for an upcoming navigation transition, so if
navigateBackToLastSuperWideRHPScreen()does not register as a navigation-kind transition, this branch would fall back towriteWhenReady's safety timeout instead of releasing on the transition. Measured on an Android emulator (dev build, Android 15, arm64), saving a split from the Spend > Expenses list:The write was deferred 1.100 s and was released by the transition, not by a fallback:
"... did not release the write"warning anywhere in the session, so it was not theSAFETY_TIMEOUT_MS(5 s) release;[TransitionTracker] waitForUpcomingTransition timed out before a transition startedbetween those two lines, so it was not theMAX_TRANSITION_START_WAIT_MS(1 s) give-up either.Barrier behaviour on web - measured, does not hit the safety timeout. ikevin127's open question was whether the
'navigation'barrier ever releases on web/desktop, or silently degrades to the 5 sSAFETY_TIMEOUT_MS. Measured on web (Chromium, local rsbuild dev server against staging), saving a selfDM split from the split-expenses flow:Both timestamps are taken inside the page in one clock, so no tooling round-trip is included. The barrier released on the real screen transition at 245 ms - roughly 20x below the fallback. This also covers the selfDM exit on web, one of the two branches raised in finding 5.
Scope of this measurement, stated plainly: one run, web/Chromium only, dev build. Desktop/Electron is not separately measured, though it shares the same
@react-navigation/stacknavigator thatScreenLayout.tsx:33listens to (native usesnative-stack). The default and Search exits on web are still unmeasured.Structurally,
ScreenLayout.tsx:33is the only emitter ofstartTransition('navigation')in the codebase, driven by React Navigation'stransitionStart/transitionEnd; web resolves that through@react-navigation/stack7.8.5, which is why the barrier has a real transition to release on there.So
navigateBackToLastSuperWideRHPScreen()does register as a navigation transition and this branch releases on it. The practical consequence is worth a reviewer's eyes: the optimistic split rows land ~1.1 s after the Save press on this device, so there is a visible gap between the press and the split appearing at the destination. That is the intended trade-off of deferring rather than a defect, but it is a real behaviour change.Two smaller behaviours confirmed on the same device while measuring: pressing Save with no edits issues no API call at all, and a split row left at 0.00 is blocked by
Please enter a valid amount before continuingrather than being written.Fixed Issues
$ #97802
PROPOSAL: #97802 (comment)
Tests
Failure scenarios
Automated coverage added in
tests/actions/IOUTest/SplitTest.ts(split save deferred write):writeWhenReadywith a barrierupdateSplitTransactionscall outside the split-expenses flow is not deferredBoth suites mock
writeWhenReadyto run the write inline: no screen transition happens in a test, so the barrier would otherwise hold the write until its safety timeout and every optimistic-data assertion would fail. The barrier-gated path itself is asserted by the three tests above rather than mocked away.Verified locally at the current head:
SplitTest.ts,SplitSelfDMTest.ts,deferredLayoutWriteTest.ts,SplitReportTotalsTest.ts- 193 passed, 0 failed (4 suites).typecheck-tsgoclean, ESLint 0 errors on all changed files,react-compiler-compliance-checkpasses.Offline tests
QA Steps
Same as tests.
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectionAvatar, I verified the components usingAvatarare working as expected)StyleUtils.getBackgroundAndBorderStyle(theme.componentBG))npm run compress-svg)Avataris modified, I verified thatAvataris working as expected in all cases)Designlabel and/or tagged@Expensify/designso the design team can review the changes.mainbranch was merged into this PR after a review, I tested again and verified the outcome was still expected according to theTeststeps.Screenshots/Videos
Android: Native
native.mov
Android: mWeb Chrome
android-nweb.mov
iOS: Native
ios-record-expensify.mov
iOS: mWeb Safari
nweb-expensify.mov
MacOS: Chrome / Safari
Screen.Recording.2026-08-07.at.21.24.39.mov