Skip to content

perf: defer the split-save API.write behind the destination layout - #98012

Open
Abdukhamid000 wants to merge 18 commits into
Expensify:mainfrom
Abdukhamid000:fix/97802-defer-split-save
Open

perf: defer the split-save API.write behind the destination layout#98012
Abdukhamid000 wants to merge 18 commits into
Expensify:mainfrom
Abdukhamid000:fix/97802-defer-split-save

Conversation

@Abdukhamid000

@Abdukhamid000 Abdukhamid000 commented Aug 6, 2026

Copy link
Copy Markdown

Explanation of Change

updateSplitTransactions was the only IOU write-then-navigate flow that never deferred its API.write. API.write applies optimisticData synchronously, 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 for requestAnimationFrame on two of its four exit branches, which fires before the paint.

Three changes:

  1. Defer the write behind a navigation barrier. API.writeWhenReady(command, params, onyxData, createTransitionBarrier('navigation')), gated on isFromSplitExpensesFlow. Per JakubKorytko on this PR, deferOrExecuteWrite is being migrated out and removed shortly, so this uses writeWhenReady instead - 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.
  2. Drop three forward-only subscriptions on SplitExpensePage (folding in yusufdeveloper2903's proposal). REPORT_NAME_VALUE_PAIRS, SNAPSHOT and PERSONAL_DETAILS_LIST were each read with useOnyx and 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 existing getAllReportNameValuePairs, getAllSnapshots and getAllPersonalDetails getters, all already backed by module-level caches in actions/IOU/index.ts, so no new Onyx subscription is added. Correction to an earlier version of this body: those getters do sit on plain Onyx.connect (src/libs/actions/IOU/index.ts:11,74), whose JSDoc reads This 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 to connectWithoutView is out of scope here and belongs in its own PR.
  3. Scan allTransactionsList once instead of twice per press (folding in dilshodmackbook-sketch's dedup). The two consumers disagree on how a missing expenseReportID should behave, so the guard moved onto the derived value rather than onto the scan, keeping both behaviours byte-identical.

Per the review notes, allTransactionsList and POLICY_TAGS are deliberately left alone - the former would lose Search-snapshot-only transactions, and getPolicyTags is slated for removal in #72721. No hash-scoped SNAPSHOT selector and no getAllTransactionViolations.

Every navigation decision is computed from params.allTransactionsList before any write, so deferring cannot change where the user lands. The emitted onyxData is 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: writeWhenReady does not reach prepareRequest until the barrier releases, so there is a window - bounded by SAFETY_TIMEOUT_MS, currently 5 * 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. apiWrite persisted 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 synchronous API.write. Jakub, who wrote both the primitive and that caveat, ruled that the wording is the problem rather than this PR: SendMoney already defers its write today via deferOrExecuteWrite with 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 in writeWhenReady.ts so 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 main today SendMoney.ts:557 (SEND_MONEY_ELSEWHERE / SEND_MONEY_WITH_WALLET), Split.ts:370 and :488, PerDiem.ts:1119 and :1217, and SendInvoice.ts:874 all defer via deferOrExecuteWrite. There is a flushOnBackground best-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 pointerdown rather than the interaction's longest event, under-reporting INP by roughly 8-10x. The replacement figure I quoted - API.write at 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 to writeWhenReady'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:

19:27:25.648  [API] Called API writeWhenReady - {"command":"Transaction_Split", ...}
19:27:26.748  [API] Called API write          - {"command":"Transaction_Split", ...}

The write was deferred 1.100 s and was released by the transition, not by a fallback:

  • no "... did not release the write" warning anywhere in the session, so it was not the SAFETY_TIMEOUT_MS (5 s) release;
  • no [TransitionTracker] waitForUpcomingTransition timed out before a transition started between those two lines, so it was not the MAX_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 s SAFETY_TIMEOUT_MS. Measured on web (Chromium, local rsbuild dev server against staging), saving a selfDM split from the split-expenses flow:

Save press          t =   0 ms   (capture-phase click listener, in-page)
Transaction_Split   t = 245 ms   (patched window.fetch, same performance.now() clock)
SAFETY_TIMEOUT_MS   never reached (5000 ms = 5 * CONST.MAX_TRANSITION_DURATION_MS)

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/stack navigator that ScreenLayout.tsx:33 listens to (native uses native-stack). The default and Search exits on web are still unmeasured.

Structurally, ScreenLayout.tsx:33 is the only emitter of startTransition('navigation') in the codebase, driven by React Navigation's transitionStart/transitionEnd; web resolves that through @react-navigation/stack 7.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 continuing rather than being written.

Fixed Issues

$ #97802
PROPOSAL: #97802 (comment)

Tests

  1. Open the Search (Spend) page and click an expense that is not already split
  2. Click More -> Split
  3. Adjust the split amounts (or leave the even split) and press Save
  4. Verify you land back on the Search page and the expense now shows its split children
  5. Repeat starting from a report: open a report -> open an expense -> More -> Split -> Save, and verify you land on the report with the split applied
  6. Repeat for a self-DM tracked expense and verify you land on the self-DM report, with no "Not Found" flash
  7. Reduce an existing split back to a single item and press Save (reverse split); verify the original expense is restored and you are not stranded on a "Not Found" page

Failure scenarios

  1. With the network throttled to fail, perform a Save; verify the optimistic split is rolled back and the error is surfaced on the transaction rather than left applied
  2. Press Save twice in quick succession; verify only one split is created and no duplicate rows appear on the destination
  3. Start a Save and immediately navigate away before the destination finishes laying out; verify the write still executes (the channel's safety timeout covers a destination that never mounts) and the split is not lost

Automated coverage added in tests/actions/IOUTest/SplitTest.ts (split save deferred write):

  • saving through the split-expenses flow routes the write through writeWhenReady with a barrier
  • the same barrier-gated path is used when saving from the Search page, confirming the barrier is route agnostic
  • a direct updateSplitTransactions call outside the split-expenses flow is not deferred

Both suites mock writeWhenReady to 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-tsgo clean, ESLint 0 errors on all changed files, react-compiler-compliance-check passes.

  • Verify that no errors appear in the JS console

Offline tests

  1. Turn off your network connection
  2. Perform the Save from Search, from a report, and from a self-DM tracked expense as in the Tests section
  3. Verify the split still appears optimistically in each case and you land on the expected destination
  4. Verify the Search page still highlights the newly created split rows while offline (the highlight is registered before the write and does not wait on it)
  5. Restore the connection and verify the queued writes are sent and no rows are duplicated

QA Steps

Same as tests.

  • Verify that no errors appear in the JS console

PR Author Checklist

The unchecked boxes below are the per-platform screenshots and on-device runs still outstanding; they are tracked in the review thread rather than blocking on draft status. Nothing is checked that was not actually verified. Items phrased "If X ..." are checked where X does not apply and that was confirmed against the diff (no CSS, no assets, no copy, no UI change, no generic component, no Storybook stories, no message-composer code).

  • 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
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

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.
@JakubKorytko

Copy link
Copy Markdown
Member

Hi! I'm really happy to see you want to use my solution. That being said, please use API.writeWhenReady instead of deferOrExecuteWrite. The latter will be migrated and removed in the coming days, so please use the former to avoid conflicts.

@Abdukhamid000
Abdukhamid000 force-pushed the fix/97802-defer-split-save branch from af39069 to e997be4 Compare August 7, 2026 10:33
@github-actions

github-actions Bot commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

All contributors have signed the CLA ✍️ ✅
Posted by the CLA Assistant Lite bot.

@Abdukhamid000 Abdukhamid000 removed their assignment Aug 7, 2026
@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.
@Abdukhamid000

Copy link
Copy Markdown
Author

@codex review

@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: 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".

Comment thread src/libs/actions/IOU/SplitTransactionUpdate.ts Outdated
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.
@Abdukhamid000

Copy link
Copy Markdown
Author

I have read the CLA Document and I hereby sign the CLA

@Abdukhamid000

Copy link
Copy Markdown
Author

recheck

@Abdukhamid000
Abdukhamid000 marked this pull request as ready for review August 11, 2026 07:47
@Abdukhamid000
Abdukhamid000 requested review from a team as code owners August 11, 2026 07:47
@melvin-bot
melvin-bot Bot requested review from a team, JmillsExpensify and ikevin127 and removed request for a team August 11, 2026 07:47
@melvin-bot

melvin-bot Bot commented Aug 11, 2026

Copy link
Copy Markdown

@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]

@melvin-bot
melvin-bot Bot requested review from rlinoz and removed request for a team August 11, 2026 07:47
@melvin-bot

melvin-bot Bot commented Aug 11, 2026

Copy link
Copy Markdown

@rlinoz 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]

@melvin-bot
melvin-bot Bot removed the request for review from a team August 11, 2026 07:47

@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: 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".

Comment thread src/libs/actions/IOU/SplitTransactionUpdate.ts Outdated

@JmillsExpensify JmillsExpensify left a comment

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.

No product review required.

@rlinoz rlinoz left a comment

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.

Looking good, but I think we can improve the design here.

Also, can you please sign your commits 🙇

Comment on lines +23 to +26
*
* 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.

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.

Suggested change
*
* 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.

Comment thread src/pages/iou/SplitExpensePage.tsx Outdated
Comment on lines +374 to +375
allReportNameValuePairsList: getAllReportNameValuePairs(),
allSnapshots: getAllSnapshots(),

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.

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

const expenseReportNameValuePairs = allReportNameValuePairsList?.[`${ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS}${expenseReport?.reportID}`];
and that only needs the rNVP for a specific reportID which we should be able to derive for the selector.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

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.

@ikevin127

ikevin127 commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

Also, can you please sign your commits 🙇

@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.

@ikevin127 a question before I touch anything, since it runs into your earlier note.

verifySignedCommits is failing — all 11 commits on this branch are unsigned. The check has no allowlist, it fails if any commit in the PR is unverified, and signing a commit changes its SHA, so the only way to clear it is to amend all 11 and force push.

That is exactly what you asked me not to do on 2026-08-15 (CONTRIBUTING.md:200, never force push once review has started), which is why I would rather ask than assume.

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).

@Abdukhamid000
Abdukhamid000 force-pushed the fix/97802-defer-split-save branch from 6209581 to ad21c9d Compare August 19, 2026 05:11
@Abdukhamid000

Copy link
Copy Markdown
Author

@rlinoz — signed commits are pushed. All 12 commits on the branch now show as Verified, so Verify signed commits should be satisfied. The force-push was cleared by @ikevin127 for exactly this reason.

Two things:

1. The workflows need approving again. The push reset them — all 15 runs, including Verify signed commits itself, are sitting at action_required. Same ask as the first time 🙏

2. Both of your review comments are addressed. The writeWhenReady mock docblock paragraph is deleted, and SplitExpensePage no longer touches the Onyx.connect getters — getAllReportNameValuePairs(), getAllSnapshots() and getAllPersonalDetails() are gone, replaced with useOnyx reads alongside the allReports / allReportActions ones already in that file.

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 — allReportNameValuePairsList has a second consumer at SplitTransactionUpdate.ts:1375 keyed on each splitTransaction?.reportID (not knowable at render time), and allSnapshots is scanned wholesale at three sites by design. Happy to do the selectors, but that means reworking those call sites, so I'd rather confirm the shape first.

Other than those two changes, the history rewrite was signature-only — the diff is byte-identical to what you reviewed.

@codecov

codecov Bot commented Aug 19, 2026

Copy link
Copy Markdown

Codecov Report

✅ Changes either increased or maintained existing code coverage, great job!

Files with missing lines Coverage Δ
src/libs/actions/IOU/SplitTransactionUpdate.ts 91.29% <100.00%> (+0.11%) ⬆️
... and 535 files with indirect coverage changes

@rlinoz

rlinoz commented Aug 19, 2026

Copy link
Copy Markdown
Contributor

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?

@Abdukhamid000

Copy link
Copy Markdown
Author

You're right. Whole-collection useOnyx re-subscribes the page, so that optimization was gone either way — I've dropped it. SplitExpensePage is now identical to main.

Selectors don't fit: allReportNameValuePairsList is also read per split transaction at SplitTransactionUpdate.ts:1375, keyed on IDs only known at save time.

This PR is now just the deferred write. Sorry for the noise above.

…2-defer-split-save

# Conflicts:
#	tests/actions/IOUTest/SplitTest.ts
@Abdukhamid000
Abdukhamid000 requested a review from rlinoz August 19, 2026 22:49
Comment on lines +1985 to +1989
// 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.

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.

One last thing, can we simplify this comment please?

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done — two lines now, in 3f3b68d.

@Abdukhamid000
Abdukhamid000 requested a review from rlinoz August 24, 2026 13:43
rlinoz
rlinoz previously approved these changes Aug 24, 2026
@rlinoz

rlinoz commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

@ikevin127 can you complete the checklist please?

@mountiny

Copy link
Copy Markdown
Contributor

@JakubKorytko Can you please review this PR as well?

@JakubKorytko

Copy link
Copy Markdown
Member

Yeah, I took a look earlier but will do a proper review today

@JakubKorytko JakubKorytko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

some comments

Comment thread tests/actions/IOUTest/SplitSelfDMTest.ts
Comment thread tests/actions/IOUTest/SplitTest.ts Outdated
Comment thread src/libs/API/__mocks__/writeWhenReady.ts Outdated
Comment thread src/libs/API/__mocks__/writeWhenReady.ts Outdated
Comment thread src/libs/API/__mocks__/writeWhenReady.ts Outdated
Comment on lines +36 to +40
/**
* 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.
*/

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Suggested change
/**
* 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

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Cut to one sentence.

Comment thread tests/actions/IOUTest/SplitTest.ts

@JakubKorytko JakubKorytko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

a lot better now, one comment not super important though

Comment thread tests/actions/IOUTest/SplitTest.ts

@JakubKorytko JakubKorytko left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

won't harass you anymore, thanks for addressing the review

@rlinoz

rlinoz commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

bump @ikevin127 on the checklist

@ikevin127 ikevin127 left a comment

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.

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
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

@melvin-bot
melvin-bot Bot requested a review from rlinoz August 27, 2026 18:35
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.

7 participants