Skip to content

Split MoneyRequestConfirmationList - #101198

Merged
mountiny merged 39 commits into
Expensify:mainfrom
callstack-internal:VickyStash/refactor/99449-split-MoneyRequestConfirmationList
Sep 25, 2026
Merged

mountiny merged 39 commits into
Expensify:mainfrom
callstack-internal:VickyStash/refactor/99449-split-MoneyRequestConfirmationList

Conversation

@VickyStash

@VickyStash VickyStash commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

Explanation of Change

Next step of splitting IOURequestStepConfirmation.
Step 1 (#100041) forked MoneyRequestConfirmationListFooter per expense type behind a footer dispatcher. This PR does the same one level up: MoneyRequestConfirmationList.tsx becomes an index.tsx dispatcher plus six variants, and the footer dispatcher is deleted because each list variant now renders its own footer directly. The old double dispatch (list type → footer type) collapses into one.

Perf measurements

PerDiem/Time wins most

Time image image image
Per Diem image image image
Manual image image image

Fixed Issues

$ #99449
PROPOSAL: N/A

Tests

  • Verify that no errors appear in the JS console

Every expense confirmation path goes through this component, so the matrix below is the test. For each row, open the confirmation step, verify the fields the type shows on main are present and no others, edit one field, and submit. Verify the submitted expense matches what was confirmed.

Create, from global + or from a workspace chat

  1. Manual: amount, merchant, date, category/tag if the workspace has them, tax if enabled. Toggle billable/reimbursable.
  2. Scan: one receipt. Verify compact mode, "Show more", receipt thumbnail, and that a PDF receipt loads. Enter amount/merchant manually if the scan fails.
  3. Multi-scan: three receipts. Use Prev/Next to switch between them; verify there is no flash to the manual layout. Remove one receipt from the confirmation; verify the remaining two still confirm.
  4. Distance, map: two waypoints. Verify the map, distance, rate, and the amount recalculating when the rate changes.
  5. Distance, manual: enter a distance. Verify no map, rate field present.
  6. Distance, odometer: start/end readings with receipts. Verify the stitched receipt and its loading state.
  7. Per diem: destination, dates, sub-rates. Verify no receipt, no tax field, amount from sub-rates.
  8. Time: hours and rate. -> amount from hours × rate.
  9. Split: manual and scan, with two participants. Verify the paid-by row, per-participant amounts, and the split amount editor.
  10. Invoice: from an invoice room. Verify the sender field and the "Send invoice" button.
  11. Pay: send money to a user. Verify the settlement button instead of the submit button.

Track expense (self DM)

  1. Move a tracked expense to a workspace via Submit. Verify that the confirmation shows the workspace's fields and submits the request.
  2. Move a tracked per diem via Submit. Verify it confirms with the manual layout (no sub-rate fields).

Edit

  1. Open split details for an existing split (DynamicSplitBillDetailsPage). Verify the read-only list, the receipt, and editing amounts when the split is editable.
  2. Share a receipt into the app (SubmitDetailsPage). Verify the receipt shows and submits.

Offline tests

Same, as in Tests section

QA Steps

// TODO: These must be filled out, or the issue title must include "[No QA]."

  • Verify that no errors appear in the JS console

Same, as in Tests section

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

Manual
manual.mp4
Scan / Multi-scan
scan.mp4
Distance, map
distance_map.mp4
Distance, manual
distance_manual.mp4
Distance, odometer
distance_odometr.mp4
Per diem
perdiem.mp4
Time
time.mp4
Split
spllit_scan.mp4
split_manual.mp4
Invoice
invoice.mp4
Pay
pay.mp4
Submit expense from self dm
submit_wk.mp4
Submit Per Diem from self dm
submit_perdiem.mp4
Split details
view_split.mp4
Share
share.mov

@codecov

codecov Bot commented Sep 16, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Looks like you've decreased code coverage for some files. Please write tests to increase, or at least maintain, the existing level of code coverage. See our documentation here for how to interpret this table.

Files with missing lines Coverage Δ
...uestConfirmationList/ConfirmationFooterContent.tsx 100.00% <100.00%> (ø)
...RequestConfirmationList/ConfirmationListLayout.tsx 100.00% <100.00%> (ø)
...uestConfirmationList/DistanceRequestController.tsx 79.22% <100.00%> (-3.22%) ⬇️
...MoneyRequestConfirmationList/FieldAutoSelector.tsx 66.66% <100.00%> (ø)
...neyRequestConfirmationList/SplitBillController.tsx 54.16% <100.00%> (ø)
...nts/MoneyRequestConfirmationList/TaxController.tsx 95.23% <100.00%> (+2.38%) ⬆️
...estConfirmationList/hooks/useConfirmationAmount.ts 95.00% <100.00%> (ø)
...onfirmationList/hooks/useConfirmationPolicyData.ts 100.00% <100.00%> (ø)
...tConfirmationList/hooks/useConfirmationSections.ts 100.00% <ø> (ø)
.../components/MoneyRequestConfirmationList/index.tsx 100.00% <100.00%> (ø)
... and 15 more
... and 9 files with indirect coverage changes

@VickyStash

Copy link
Copy Markdown
Contributor 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: 168d14194b

ℹ️ 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/pages/iou/request/step/IOURequestStepConfirmation.tsx Outdated
@situchan

Copy link
Copy Markdown
Contributor

a few hrs

@VickyStash

Copy link
Copy Markdown
Contributor Author

@situchan any news?

@situchan

Copy link
Copy Markdown
Contributor

Looks good so far

@situchan

situchan commented Sep 24, 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 any callback methods that were added or modified are named for what the method does and never what callback they handle (i.e. toggleReport and not onIconClick).
    • 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 and each prop has a /** comment above it */
    • The file is named correctly
    • 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
    • For Class Components, any internal methods passed to components event handlers are bound to this properly so there are no scoping issues (i.e. for onClick={this.submit} the method this.submit should be bound to this in the constructor)
    • Any internal methods bound to this are necessary to be bound (i.e. avoid this.submit = this.submit.bind(this); if this.submit is never passed to a component event handler like onClick)
    • All JSX used for rendering exists in the render method
    • 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 any new file was added I verified that:
    • The file has a description of what it does and/or why is needed at the top of the file if the code is not self explanatory
  • 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: mWeb Chrome
iOS: HybridApp
ios1.mp4
ios2.mp4
iOS: mWeb Safari
MacOS: Chrome / Safari
web1.mov
web2.mov
web3.mov
web4.mov

@situchan

situchan commented Sep 24, 2026 •

Copy link
Copy Markdown
Contributor

Please fix conflict

@MelvinBot

Copy link
Copy Markdown
Contributor

🤖 Review: the split is faithful. I diffed all six variants and the extracted hooks against the old 715-line monolith and found no dispatch, prop, or side-effect regression. Three things are worth your attention before merge — one test gap and two behavior changes that aren't refactors.

1. The distance footer's 3-way branch lost its only test coverage

The deleted MoneyRequestConfirmationListFooter.test.tsx asserted manual → DistanceManualFooter, odometer → DistanceOdometerFooter, else → DistanceMapFooter. That selection moved to renderFooter() in variants/DistanceConfirmationList.tsx:72-85, but the new dispatcher test mocks DistanceConfirmationList out entirely and only asserts that all five distance request types land on it. Nothing in tests/ now references DistanceManualFooter, DistanceOdometerFooter, or DistanceMapFooter.

The branch also got easier to break in the move: isManualDistanceRequest is re-derived locally from the transaction at line 37, while isOdometerDistanceRequest still arrives as a prop at line 29 — two sources feeding one untested branch. Please add a DistanceConfirmationList test covering all three.

2. shouldCalculatePerDiemAmount dropped its currency term

hooks/useConfirmationAmount.ts:77 no longer includes || prevCurrency !== currency. Your comment explains the reasoning and computePerDiemExpenseAmount genuinely doesn't read currency, so I think this is fine — but it's a logic change in a PR billed as a pure refactor, it has no test either before or after, and the parallel guard in useDistanceRequestState.ts:112 still keeps its currency check. Worth calling out in the PR description so a reviewer doesn't have to rediscover it.

3. The usePreviousDefined hold changes navigation pre-mount timing

IOURequestStepConfirmation.tsx:168-175 keeps transaction defined across the useOnyx key switch. That's load-bearing — without it a multi-scan Prev/Next drops to ManualConfirmationList and remounts. But isTransactionReady now stays true through a window where it used to be false, so getSubmitExpensePreMountDestinationRoute gets past its !isTransactionReady guard and preInsertFullscreenUnderRHP / removePreInsertedFullscreenIfNeeded fire where they previously didn't. That's exactly why TimeExpenseConfirmationTest.tsx needed three new Navigation mocks. No assertion was added about when pre-insert should fire — given #97437, that seems worth one.

Nit: tests/ui/MoneyRequestReportFooter.tsx:145 still says describe('MoneyRequestConfirmationListFooter') after being rewired to import ManualFooter directly.

What I verified clean

Dispatch parity. The new index.tsx branch order matches the deleted footer dispatcher case-for-case: invoice → per-diem (action !== SUBMIT) → time (action === CREATE) → scan → distance → residual manual. The one reorder (scan now tested before distance) is inert — both predicates read the single transaction.iouRequestType field and are mutually exclusive. Split, pay, track expense, per-diem-moved-from-track-expense-at-SUBMIT, and share-receipt all route as before.

Footer content. All eight footer variant files are byte-unchanged, and I checked every prop fed to them against the monolith's single old call site — policy, policyTags, selectedParticipants, amountDisplay, requiredFlags, visibilityFlags, errorState, toggleHandlers, receiptOptions, compactControls, distanceData, receiptStitchError, isCompactMode all match. ConfirmationFooterContent.tsx lost only the prop block; its JSX is textually identical and isLoadingReceipt survives via the receiptOptions default.

Controller omissions are provably inert. TaxController is dropped from PerDiem/Time — safe, isTaxTrackingEnabled hard-returns false for both (PolicyUtils.ts:1580-1582), so its effects already bailed. SplitBillController is dropped from PerDiem/Time/Invoice — shouldShowPerDiemTabOption returns false for SPLIT (IOUUtils.ts:357-360), the Time tab is gated to SUBMIT/CREATE, and invoice can't be a split. DistanceRequestController and useBlockDistanceRequest are distance-only — every effect was already gated on isDistanceRequest. FieldAutoSelector is still mounted by all six variants, so category/tag auto-select is untouched.

Hooks. Every useMemo/useCallback/useEffect dependency array in the new hooks matches the old one. No ??→|| swaps around 0/''/false. The old effect that reset both showMoreFields and isTaxAmountEmpty on transactionID change is correctly split, with showMoreFields landing in ScanConfirmationList — the only variant that has it.

Context. ConfirmationDataContext defaults to undefined and useConfirmationData() throws rather than silently returning defaults. All six variants wrap a provider, all six consumers render inside one, and footerContent is rendered inline by BaseSelectionListWithSections rather than portalled, so the provider holds.

Callsites. IOURequestStepConfirmation, SubmitDetailsPage, and DynamicSplitBillDetailsPage are 1:1 translations; the only change is six flat receipt props collapsing into receiptOptions, with the old receiptPath/receiptFilename '' defaults correctly relocated to ReceiptSection.tsx:58-59. iouType and action lost their component defaults but all three callsites pass them explicitly. No stale imports of the old file or the deleted footer dispatcher anywhere in src/ or tests/.

Minor, no action needed: ConfirmationTelemetry moved inside ConfirmationListLayout, so SPAN_CONFIRMATION_LIST_READY closes slightly later than before. Telemetry only, but it will shift that dashboard.

Web testing

I drove the refactored confirmation screens on dev web. Manual, Distance, and Split all confirmed and submitted correctly end to end — the split showed the "Paid by" row and correct $10.00/$10.00 per-participant amounts on a $20.00 total. No non-2xx API responses during any confirmation flow.

Scan could not be tested: attaching a receipt needs a native file-picker dialog, which is out of scope for web automation.

Manual expense confirmation page Distance expense confirmation page Split expense confirmation page with paid-by row

One unconfirmed observation: at the short viewport above (1280×577), Date on Manual and Distance/Rate on Distance were in the accessibility tree with correct values but had no visible row, and scrolling could not bring them into view. I could not reproduce this against main for comparison, and none of the code review turned up a layout change that would explain it, so I suspect it's a harness artifact rather than a regression. If it's cheap, a sanity check of the confirmation page at a short viewport height would rule it out.

…ConfirmationList

# Conflicts:
#	src/components/MoneyRequestConfirmationList/DistanceRequestController.tsx
@MelvinBot

Copy link
Copy Markdown
Contributor

🤖 Code quality review. The mechanical hygiene here is genuinely good — zero any, zero non-null assertions, zero widening casts, no inline styles, no TODOs, every non-trivial new file has a header, and the new tests read well. Two structural issues are worth addressing, and one of them works against this PR's own performance goal.

1. The context bag un-memoizes the four controllers — a perf regression vs main

hooks/useConfirmationListData.ts:277-354 returns one large object that every variant passes straight into ConfirmationDataContext.Provider value={data}. Any change to any key — formError on a keystroke, formattedAmount, isConfirming — gives data a new identity, so every consumer re-renders.

On main these controllers took scalar/stable-object props, so the compiler cached their elements and React skipped them when unrelated state moved. FieldAutoSelector and SplitBillController were genuinely skippable and now never are. And ConfirmationListLayout.tsx:46 subscribes to the whole blob to read one boolean (isReadOnly) that the variant already has as a prop.

The hook already returns layoutProps / errorState / visibilityFlags as bundles — do the same for the controllers (taxControllerProps, fieldAutoSelectProps, …), pass them as props, and the compiler memoizes each independently. Measured consumption, for sizing: the layout reads 1 key, SplitBillController 7, TaxController 9, FieldAutoSelector 10, DistanceRequestController 14, ConfirmationFooterContent 18. Nobody is close to using most of it, which is what CLEAN-REACT-5 keys on.

One caveat if you do stabilize it: TaxController.tsx:28 and DistanceRequestController.tsx:77 hold usePrevious values that only advance once per parent render because the context value is unstable today. Stabilizing naively changes that silently.

Minimum viable version if the full change is too much: move isReadOnly into layoutProps and drop the context read from the layout.

2. The six variants are 75–85% identical — CONSISTENCY-3

Pairwise identical-line counts: Manual↔Scan 48/60, Manual↔Invoice 45/60, Time↔PerDiem 41/55. ConfirmationListLayout absorbed the inner half, but the outer half — the provider, the controller mount list, ConfirmationFieldsProvider, the <View> wrapper, and a 7–9 line footer prop bundle — is hand-assembled in all six.

Give ConfirmationListLayout {data, listFooterContent, children} so it owns the provider and takes the controllers as children. Each variant then ends with a 5-line return whose only difference is which controllers mount — which is the one thing that genuinely varies. That's the real separation; right now Manual, PerDiem, Time, and Invoice are pass-through shells that forward a prop bag, so the split bought real ownership for 2 of 6 and relabeling for 4 of 6.

3. A dozen values are reachable by two or three paths on the same object

transactionID is at layoutProps.transactionID, confirmationFieldsProviderProps.transactionID, and data.transactionID. Same for formattedAmount, setFormError, clearFormErrors, shouldShowTax, shouldShowCategories, isCategoryRequired, isReadOnly, policyID, reportID, iouType, isPolicyExpenseChat. The call sites already mix them — ScanConfirmationList.tsx:30 reads data.layoutProps.transactionID while data.transactionID exists. Pick one shape; shipping both guarantees a future edit updates one path and not the other.

Related: ConfirmationDataContext.ts:6 types the context as ReturnType<typeof useConfirmationListData>, so nothing declares the contract and nothing flags it growing. Declaring ConfirmationData explicitly would also make the duplicated paths one reviewable block.

Quick wins

  • isParticipantPickerVisible is re-spread in four variants (Manual:38, Scan:60, Invoice:34, Distance:66) and omitted in PerDiem/Time. It's already on MoneyRequestConfirmationListProps; the hook just never destructures it. Fold it into visibilityFlags and the 4-vs-2 asymmetry — three chances for a seventh variant to silently forget — goes away. onTaxAmountEmptyChange and isEditingSplitBill have the same 4-vs-2 shape.
  • DistanceRequestControllerProps.distanceState takes all 19 keys to use 10, including internal bookkeeping (prevRate, prevUnit, policyDraft). Its sibling TaxController.tsx:17 already does the Pick correctly — match it.
  • useConfirmationPolicyData walks the policy_ collection twice. Line 16 calls usePolicyForMovingExpenses(), and line 18's usePolicyForTransaction calls it again internally. They don't dedupe — Onyx keys its cache on (key, selector) and each instance builds its own closure. Not a regression (the monolith did it too), but it's now in a 27-line file every variant calls, so it's the cheapest remaining win. Have usePolicyForTransaction return the value it already computes.
  • Duplicated derivations. iouAmount/iouCurrencyCode are computed identically in useConfirmationListData and useDistanceConfirmationListData; isManualDistanceRequestUtil(transaction) is called three times on one path (DistanceConfirmationList:37, useDistanceConfirmationListData:42, DistanceRequestController:58).
  • didConfirm is derived state — PERF-6. useConfirmationListData.ts:143 plus a render-phase setState and a sync effect; the only writers are those two sites, so the steady state is exactly isConfirmed && !isEditingSplitBill. Collapsing it removes a useState, an effect, and an eslint-disable. It's byte-identical to main though, and it shifts transient ordering — so a follow-up issue is the safer call for a parity-verified refactor.
  • Three new eslint-disable react-hooks/set-state-in-effect (useConfirmationListData.ts:148, :195, ScanConfirmationList.tsx:39) where the monolith had zero disables. The code is the same; the suppressions are new. That's above the repo norm already, so a nit — but ScanConfirmationList.tsx:39's justification restates the line below it instead of justifying the suppression.

Docs, comments and naming

  • types.ts:95 is factually wrong: /** CREATE, SPLIT or SUBMIT */ on action: IOUAction. SPLIT is an IOUType, not an IOUAction, and categorize/share/edit all reach here. Delete it or state what actually matters — only CREATE and SUBMIT change which variant the dispatcher picks.
  • Six prop docs lost their non-obvious clause in the move to types.ts (lines 20, 23, 77, 80, 83, 110). CONSISTENCY-10 flags exactly this. Line 23 now also disagrees with fieldGroupTypes.ts:43, which kept the full text for the same flag.
  • index.tsx:28 dropped the "why". The deleted footer dispatcher said per-diem-off-a-track-expense "submits through RequestMoney rather than CreatePerDiemExpense"; the replacement only says which component renders. The real reason survived in the new test at MoneyRequestConfirmationList.test.tsx:112 but not in the code.
  • Rename data → confirmationData at the six destructure sites. It's dereferenced 8–10 times per file, so the prefix is load-bearing text carrying no meaning, and the type is already ConfirmationData.
  • SplitBillController.tsx:19 aliases setFormError: onFormError — that inverts CONSISTENCY-12, naming an action after an event slot that no longer exists. It was only done so the effect body didn't have to change.
  • selectedParticipantsProp is now a public member of the context read three files away, where there's no prop to shadow. It means "before the .selected filter" — allSelectedParticipants would say so, and would remove the need for a comment explaining why there are two.
  • Typo: ManualConfirmationList.tsx:15 — "expense.It also serves".
  • tests/ui/MoneyRequestReportFooter.tsx has stale naming and ~13 lines of dead mock. The render was rewired to ManualFooter but renderMoneyRequestConfirmationListFooter (:77), testID (:136), and describe (:145) still name the deleted component. Worse, ManualFooterProps omits isCompactMode, compactControls, and distanceData, yet defaultProps still supplies all three (:95, :104-114, :132) — it only typechecks because a JSX spread skips excess-property checking. Delete them.
On the claimed perf win, and things I checked that are clean

The win is structurally real but about half-realized. PerDiem/Time genuinely skip useDistanceRequestState (3 useOnyx subs + 6 usePrevious), useBlockDistanceRequest (a whole-policy_ reducing selector), DistanceRequestController (5 effects, ~18 deps each), TaxController + useTaxAmount, SplitBillController, and useIsInLandscapeMode. So "PerDiem and Time improve most" matches the code, not just the traces.

What's still unconditional inside useConfirmationListData, which every variant calls: the double policy_ walk above; policyForMovingExpenses/shouldSelectPolicy, only ever read when moving a track expense; the isTaxAmountEmpty state and its reset effect, which run for PerDiem/Time even though neither wires onTaxAmountEmptyChange; and useParticipantSection → useSplitParticipants, which early-returns for non-splits but still pulls useThemeStyles + useLocalize + useCurrencyListActions.

On the effect-only controllers — keep them as components. The usual objection ("hooks can't be conditional") dissolves here since each variant is its own component. But the ordering does matter: as siblings rendered before ConfirmationListLayout, their effects flush before the footer fields', so TaxController seeds taxCode/taxAmount before TaxFields reads it. As hooks in the variant body they'd run after the children's. That's the real reason for the shape and nothing in the code says so — worth a line in the doc comments before someone "simplifies" them.

On usePreviousDefined at IOURequestStepConfirmation.tsx:168-175: the diagnosis is right and the isLoadingCurrentTransaction guard is correct, but it holds a stale transaction for the whole subtree, not just the dispatch decision, and the hook reads a ref during render (with its own react-hooks/refs disable). The non-flickering value is already in scope — transactions.at(currentTransactionIndex) from useOptimisticDraftTransactions is available synchronously on the multi-scan Prev/Next case the comment describes. PERF-7 does not apply; it prescribes remount-via-key to reset state, and this needs the opposite.

Clean: typing hygiene (no any, no !, no as in added lines); UI-3 (no new inline styles); CONSISTENCY-2, 11, 16; CLEAN-REACT-6, 7, 8, 9; PERF-9, 13, 2, 5; CONSISTENCY-13 (all nine non-trivial new files carry headers); CONSISTENCY-4 (every returned key has a consumer). The dispatcher itself is clean — 49 lines, no state, no hooks, no data fetching, and it deliberately orders cheap boolean checks ahead of the transaction predicates. Onyx selector shapes in the new hooks are correct; the only PERF-11 issue is the duplication above, not the selectors. ConfirmationFooterContent and ConfirmationListLayout read their own data rather than taking prop bags — those two are the right shape, the variants just haven't followed them yet.

A discriminated union on the variant props is not feasible — I checked. Three branches are discriminated by props (iouType, isPerDiemRequest, isTimeRequest) but scan/distance/manual are read off transaction.iouRequestType at runtime, so no tag exists at the call site. Building one would force all three pages to duplicate the classification the dispatcher does. The narrow win that is available: isOdometerDistanceRequest and receiptStitchError are the only genuinely distance-only props — bundle them as distanceOptions?, the pattern this PR already established with receiptOptions, so the other five variants stop advertising props they can't act on.

Smaller items: hooks/types.ts holds 3 of 13 hook param types with no shared-use justification (only ConfirmationDistanceState is genuinely cross-file) — the other ten hooks colocate theirs. The four controllers plus ConfirmationTelemetry sit at the directory root beside index.tsx, types.ts, and the layout, while variants/, hooks/, sections/ are folders — a controllers/ folder would make the root readable. variants/*.tsx import siblings via @components/MoneyRequestConfirmationList/… while index.tsx and ConfirmationListLayout.tsx use ./…. And the three no-op {...props, isPerDiemRequest: true}-style overrides (PerDiem:16, Time:21, Invoice:19) re-assert what the dispatcher already guaranteed — Scan doesn't, so the defensive reading isn't consistent either; drop them or comment them.

Highest-value doc in the PR, if you add one: a short block at the top of index.tsx mapping the four layers (dispatcher → variants → data hook + context → controllers/layout/footer). A 715-line file became 40+ files and nothing states the topology.

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

I performed full regression test in create expense / track expense / send invoice / pay / split / share flow.
No issues found

@melvin-bot
melvin-bot Bot requested a review from mountiny September 24, 2026 12:02
@VickyStash

Copy link
Copy Markdown
Contributor Author

I would leave Melvin's feedback for a follow-up.

  1. The context bag un-memoizes the four controllers — a perf regression vs main

Yes, but the controllers are light, they read a few contexts and their effects only re-run when deps change. The heaviest part is useTaxAmount, and on main it already ran on every render.

  1. The six variants are 75–85% identical — CONSISTENCY-3

Give ConfirmationListLayout {data, listFooterContent, children} so it owns the provider and takes the controllers as children.

The controllers render before the layout on purpose, so their effects run before the footer fields effects. Moving them into the layout means we can change it and it should be tested.

And the third point is better to do after the first two.

@MelvinBot

MelvinBot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

🤖 Web test steps verification results: ⚠️ partial

I found no confirmed regressions. Six of seven web flows passed: workspace Manual, manual Distance, Track in self DM, Submit to workspace, Pay someone, and Time. The one failure is the short-viewport scroll check. It is most likely a test-harness artifact, because the same scroll command also failed on unrelated screens (the onboarding feature list and the workspace More features page). If it's cheap, you could check the Manual confirmation page at about 1280×577 on main and on this branch to rule it out. A code pass also found no regressions, and it confirmed that no main changes were lost in the five merges from main.
Date click landed on Create expense

Step Status
Manual expense in the workspace chat shows amount, merchant, date, and category. The edited merchant is kept on submit.The confirmation showed Amount $25.50, Merchant, Category (Advertising), and Date. I changed the merchant to "Edited Merchant" and submitted. RequestMoney returned 200, and the list shows Edited Merchant / Advertising / $25.50.
Manual expense confirmationManual expense created with edited merchant
✅
Manual distance entry shows distance and rate with no map. The submitted amount is correct.The confirmation showed 50.00 miles at $0.76/mile = $38.00, with no map. CreateDistanceRequest returned 200, and the list shows "50.00 mi @ $0.76 / mi" for $38.00.
Manual distance confirmationDistance expense created
✅
Track expense in the self DM shows the fields and lands in the self DM.The confirmation showed Amount, Merchant, Description, and Date. TrackExpense returned 200, and the $12.34 expense appears in the self DM.
Track expense confirmationTracked expense in self DM
✅
Submit (move to workspace) from the tracked expense shows the workspace fields and lands in the workspace.The confirmation showed the workspace as recipient, with a Category field set to Benefits. AddTrackedExpenseToPolicy returned 200, and the workspace report now includes the moved $12.34 expense.
Submit to workspace confirmationMoved expense in workspace report
✅
Pay someone shows a settlement button instead of a submit button.The primary action was "Pay", with the "Pay with personal account" and "Mark as paid" options. I did not complete the payment.
Pay someone confirmation
✅
Time expense amount equals hours × rate.With time tracking on at $50.00/hour, the confirmation showed 5 hours = $250.00. RequestMoney returned 200, and the list shows "5 hours @ $50.00 / hour".
Time expense confirmationTime expense created
✅
At 1280×577, every Manual confirmation field row is reachable by scrolling.Only Amount, Merchant, Description, and part of Category were visible. The scroll command did not move the panel, and it did not move the other screens mentioned above either. Clicking the off-screen Date field by its accessibility selector landed on the sticky "Create expense" button. This was not compared against main.
Manual confirmation at 1280x577Date click landed on Create expense
❌
Code regression pass

No merge regressions. Across the five merges from main, only one change to a deleted file came in: Deduct home-office distance from valid requests when enabled. Both of its hunks were ported by hand, at useDistanceConfirmationListData.ts:33 and DistanceConfirmationList.tsx:54. main has not touched the deleted files since your last merge, and the branch merges into current main with no conflicts.

No behavior regressions. I checked remounts, effect order, callsite props, recomputed values, and values the variants now omit. The double-submit guard depends on the page's isConfirmed/isConfirming, so a variant remount can't re-enable the button (ConfirmationFooterContent.tsx:136).

One behavior change, probably harmless. On the start page, the Manual tab embeds the confirmation list, and tabs stay mounted. If you switch Manual → Time → Manual (or to Distance in a split), the shared draft keeps its ID but changes type. The dispatcher then picks a different variant, so the list remounts. On main, only the footer changed. After the remount:

  • A stale violations.* form error is cleared.
  • FieldAutoSelector runs again and re-selects a required single category.

Both changes improve on main, but this path is not a pure refactor. You could check it by hand with a workspace that has one required category.


view run · view recording

@mountiny

Copy link
Copy Markdown
Contributor

@VickyStash can you please create issue for the follow ups if you dont plan to handle it here so we dont forget (you can use melvin)

@VickyStash

Copy link
Copy Markdown
Contributor Author

@MelvinBot Can you create a one follow-up issue for points 1, 2, 3 from this comment #101198 (comment).
Make it a sub-issue for #99449.
Assign me, make it daily.

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

Large diff but looks like a fine refactoring

const routeError = Object.values(transaction?.errorFields?.route ?? {}).at(0);
const isTypeSplit = iouType === CONST.IOU.TYPE.SPLIT;
const shouldShowReadOnlySplits = isPolicyExpenseChat || isReadOnly || isScanRequest;
// Both the validation gate and the clear gate below key off this, so it is computed once here rather than

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.

We should be having empty line before comments unless its start of a block

Suggested change
// Both the validation gate and the clear gate below key off this, so it is computed once here rather than
// Both the validation gate and the clear gate below key off this, so it is computed once here rather than

@mountiny
mountiny merged commit 1d8f89a into Expensify:main Sep 25, 2026
36 of 38 checks passed
@MelvinBot

Copy link
Copy Markdown
Contributor

I created [Refactor] MoneyRequestConfirmationList follow-up: stabilize controller props, dedupe variant shell, single-path context. It's a sub-issue of [Refactor] Split IOURequestStepConfirmation, MoneyRequestConfirmationList, and useExpenseSubmission per expense type and is assigned to you. It has the Daily label, and I also added Task.

The issue covers points 1–3, in that order. It includes your two caveats: the controllers must keep rendering before the layout, and point 3 is easier after points 1 and 2. I didn't find any existing issue for this work.


view run

@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

🚧 mountiny 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/mountiny in version: 9.4.95-0 🚀

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

@MelvinBot

Copy link
Copy Markdown
Contributor

No help site changes are required for this PR, so I did not create a docs PR.

This PR is an internal refactor for performance. It splits MoneyRequestConfirmationList into a dispatcher (index.tsx) and six per-expense-type variants. It is meant to keep the confirmation step looking and working the same for every expense type.

What I checked
  • No copy changes: The PR does not touch any src/languages/*.ts file. The only translation keys in the diff (attachmentPicker.* and form errors) moved between files unchanged.
  • No new or removed fields, buttons, or settings: The variants render the same fields each expense type showed before. The PR's test matrix asks reviewers to confirm each type shows "the fields the type shows on main... and no others."
  • Controllers only changed where they get data: TaxController, SplitBillController, FieldAutoSelector, and DistanceRequestController now read shared data from a context instead of props. Their tax, split, category, tag, and distance logic is the same.
  • Routing matches existing behavior: Per diem moved from a tracked expense and time expenses outside create both still use the manual layout, as before.

Since users see no change, the existing articles under App/docs/articles still describe the behavior correctly.


view run

@OSBotify

Copy link
Copy Markdown
Contributor

🚀 Deployed to production by https://github.com/Julesssss in version: 9.4.95-4 🚀

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

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