refactor: decompose IOURequestStepScan into variant components#85571
refactor: decompose IOURequestStepScan into variant components#85571adhorodyski wants to merge 21 commits intoExpensify:mainfrom
Conversation
…-renders Add Onyx selectors to usePolicyForMovingExpenses, useDefaultExpensePolicy, and useReportAttributes so they return stable values and don't trigger re-renders when unrelated policies or reports change. - usePolicyForMovingExpenses: selector computes qualifying policy IDs + flags, per-key lookup for the actual policy object - useDefaultExpensePolicy: selector finds single group policy ID, per-key lookup for the policy - useReportAttributes: new useReportAttributesByID(reportID) hook that selects only one report's attributes (cheap deep comparison) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…etters Add getter functions for module-level caches in IOU/index.ts following the existing pattern (getAllPersonalDetails, getAllTransactionViolations, etc.). These will be used by scan variant components to read action-layer data. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…canGlobalCreate, ScanSkipConfirmation) 4 self-contained variant components for the scan screen decomposition: - ScanEditReceipt: simplest, replaceReceipt → goBack - ScanFromReport: common path, setParticipants → navigateToConfirmation - ScanGlobalCreate: shouldUseDefaultExpensePolicy → determine target → navigate - ScanSkipConfirmation: heavy path, inline requestMoney/trackExpense/startSplitBill Each variant receives zero props, reads route params via useRoute(), fetches own Onyx data via hooks. ScanFromReport has zero heavy subscriptions. ScanSkipConfirmation uses selector-optimized hooks. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the 210-line component + useReceiptScan mega-hook with a thin router that determines the scan variant (ScanEditReceipt, ScanFromReport, ScanGlobalCreate, ScanSkipConfirmation) and renders it with zero props. Router subscribes only to per-key data needed for branching: skipConfirmation, reportNameValuePairs, usePolicy. Each variant reads its own route params and Onyx data. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the 680-line monolithic component (vision-camera, gestures, animations, flash, shutter, multi-scan) with the same thin router pattern as the web version. Router determines the variant and renders it with zero props. Native camera code is now handled by the variant components which render MobileWebCameraView/DesktopWebUploadView (the native-specific Camera extraction is a follow-up). Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
… + cleanup Remove the 250-line god function from MoneyRequest.ts — its logic is now inlined in the variant components. Delete the useReceiptScan mega-hook and its test. Clean up unused imports and test helpers. Deleted: - handleMoneyRequestStepScanParticipants (MoneyRequest.ts) - MoneyRequestStepScanParticipantsFlowParams type - InitialTransactionParams type - useReceiptScan.ts hook - useReceiptScan.test.ts - handleMoneyRequestStepScanParticipants tests in MoneyRequestTest.ts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Generic capture device with onCapture(file, source). Builds on existing NavigationAwareCamera. No business logic, no IDs, no multi-scan. - Camera/types.ts: CameraProps interface - Camera/FileUpload.tsx: desktop drag-drop + file picker - Camera/CameraCapture.tsx: mobile web viewfinder + shutter + flash + gallery - Camera/index.tsx: web entry, routes mobile→CameraCapture, desktop→FileUpload - Camera/index.native.tsx: vision-camera + focus gesture + flash + gallery Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace MobileWebCameraView/DesktopWebUploadView renders with
<Camera onCapture={handleCapture} />. Each variant composes Camera +
StepScreenWrapper + LocationPermissionModal + ErrorModal via JSX.
Removes:
- isMobile() branching (Camera handles internally)
- 20+ prop threading to camera views
- Direct MobileWebCameraView/DesktopWebUploadView imports
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the two-step pattern (useOnyx for NVP → isArchivedReport) with a single useOnyx call using isArchivedReport as a selector. Returns a stable boolean — prevents re-renders when unrelated NVP fields change. Update both scan routers and ScanSkipConfirmation to use useReportIsArchived(reportID) instead of the manual pattern. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…ters The router doesn't use currentUserPersonalDetails — each variant fetches its own via useCurrentUserPersonalDetails(). Remove the dead HOC wrapper and clean up the IOURequestStepScanProps type. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ScanEditReceipt, ScanFromReport, and ScanGlobalCreate never trigger the location permission flow (setStartLocationPermissionFlow(true) is unreachable in all 3). Remove the dead state, modal JSX, GPS pre-fetch effects, and unused imports. Only ScanSkipConfirmation actually uses the location permission flow. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Isolate the GPS location permission modal into a declarative <GpsLocationGate> component. Parent sets pendingFiles when GPS permission needs prompting, gate renders LocationPermissionModal and calls onResolved when granted/denied. Removes inline location permission state + modal JSX from ScanSkipConfirmation. The Onyx subscription stays in the parent to decide the code path; the gate only handles the modal rendering. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
…pling GpsPermissionGate is a pure permission component: - active: boolean (whether the prompt is showing) - onResolved(granted: boolean) (result callback) No files, no receipts, no business concepts. The caller decides what data to attach to the permission result. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract 6 focused utilities from duplicated variant code (~150 lines saved):
- getFileSource: file → URL conversion
- bridgeCameraToValidation: Camera onCapture → validateFiles bridge
- startScanProcessSpan: telemetry span creation
- useScanFileReadabilityCheck: mount effect for file readability
- buildReceiptFiles: multi-file receipt processing loop
- createTestReceiptHandler: factory for test receipt handler
Fix onLayout wiring: router now passes onLayout from IOURequestStartPage
through to variants. Each variant exposes setTestReceiptAndNavigate via
<View onLayout={() => onLayout?.(testReceiptHandler)}>. Removes the
@typescript-eslint/no-unused-vars suppression.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
After decomposition, index.tsx and index.native.tsx were byte-for-byte identical — all platform-specific code now lives in Camera/index.native.tsx and the variant components. RN platform resolution falls through to index.tsx. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
createTestReceiptHandler was forcing a 3-param signature (files, locationPermissionGranted, isTestTransaction) onto every variant, even though isTestTransaction was dead code everywhere and locationPermissionGranted was only used by ScanSkipConfirmation. Flip the dependency: createTestReceiptHandler now accepts a simple (files: ReceiptFile[]) => void callback. Each variant's navigateToConfirmationStep only declares params it actually uses. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The onLayout callback parameter was named setTestReceiptAndNavigate, leaking test infrastructure details into the component contract. Renamed to generic handler: () => void across all variants and caller. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The test receipt feature used an inverted control flow: child variants registered a handler upward via onLayout (abusing a View layout event), parent stored it in a ref, tooltip onConfirm called the ref. This violated PERF-10, PERF-8, and CLEAN-REACT-PATTERNS-4. Move the entire test receipt flow inline into the tooltip's onConfirm handler in IOURequestStartPage. The parent already has transactionID, iouType, reportID — all action functions are callable directly. Also restores McTest participant assignment that was lost when handleMoneyRequestStepScanParticipants was deleted. Deleted: createTestReceiptHandler.ts, onLayout prop from all variants. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Rename handleCapture → onCapture, processReceipts → onFilesAccepted. Inline bridgeCameraToValidation (3-line shim) into onCapture. Inline navigateToConfirmationStep into onFilesAccepted where simple. The capture flow now reads as 3 clear steps: onCapture → validateFiles (hook) → onFilesAccepted Deleted: bridgeCameraToValidation.ts Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace nested if/else with early return guard clause for !shouldUseDefaultExpensePolicy. Reduces nesting by one level. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
ContextBranch Onyx Subscription Reduction (the big win)The old monolith loaded ~15 After decomposition, each variant only subscribes to what it needs:
The first three flows are the most common user paths. ScanSkipConfirmation legitimately needs the data, but it only loads when that specific path is active — previously all 15 subscriptions fired for everyone. God Function Elimination
React Compiler AlignmentOld code had 5 manual What This Means in Practice
Summary~80% of scan screen users (edit, from-report, global-create) get significantly fewer re-renders and faster mounts. The skip-confirmation path is functionally equivalent but no longer penalizes everyone else. |
Split Plan: IOURequestStepScan DecompositionContextBranch The branch also includes hook selector optimizations that serve as prerequisites. 20 author commits (excluding already-merged useIsInSidePanel), ~3600 lines added / ~2200 lines removed. Dependency GraphPR 1 and PR 2 can land in parallel. PR 3 depends on PR 1 + PR 2. PR 4 depends on PR 3. PR 1: Hook selector optimizations + IOU gettersTitle: perf: add selectors to derived hooks, export IOU getters Adds selectors to prevent full-collection re-renders in hooks used by the scan router and variants. Exports two getter functions from IOU actions that variants need to access module-level caches. Files
Commits
PR 2: Camera component extractionTitle: feat: extract generic Camera component with platform variants Extracts a unified Also moves Files (new)
Files (renamed)
Commits
Risk
PR 3: Scan decomposition — variants + router rewriteTitle: refactor: decompose IOURequestStepScan into router + 4 variants This is the core decomposition. Adds 4 self-contained variant components + shared utilities, then replaces the monolithic router with a thin ~50-line branching component. Deletes New files — utilities
New files — components
Modified files
Variant routing logicCommits (ordered for clean bisectability)
Risk
PR 4: Dead code cleanupTitle: chore: delete handleMoneyRequestStepScanParticipants and useReceiptScan Removes the god function and hook that the monolithic scan screen used, now fully replaced by variant components. Files
Commits
|
Explanation of Change
IOURequestStepScanwas a single large screen component with ~27 reactive Onyx subscriptions that handled every scan variant (global create, from report, edit receipt, skip confirmation) in one place. This made it expensive to render and hard to reason about — every variant subscribed to data it didn't need.This PR decomposes the screen into four focused variant components:
ScanGlobalCreate,ScanFromReport,ScanEditReceipt, andScanSkipConfirmation. Each variant only subscribes to the Onyx keys it actually needs, reducing the common-path subscription count from ~27 to ~5.Supporting changes:
index.tsx(web) andindex.native.tsx(native) as thin routers that select and render the correct variantuseReceiptScangod hook andhandleMoneyRequestStepScanParticipantsgod function; logic is now co-located with each variantCameracomponent with platform variants (index.tsx/index.native.tsx)GpsPermissionGatefromScanSkipConfirmationinto its own componentIOURequestStartPage), eliminating theonLayoutbridgeonCapture→validateFiles→onFilesAccepted)getAllTransactionDraftsandgetAllReportNameValuePairsgetters from the IOU actions layerindex.native.tsxrouter that was left over after the rewriteNo user-visible behavior changes are intended.
Fixed Issues
$ #85583
PROPOSAL:
Tests
Offline tests
N/A
QA Steps
Same as tests
PR Author Checklist
### Fixed Issuessection aboveTestssectionOffline stepssectionQA stepssectiontoggleReportand notonIconClick)src/languages/*files and using the translation methodSTYLE.md) were followedAvatar, 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.ScrollViewcomponent to make it scrollable when more elements are added to the page.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
Android: mWeb Chrome
iOS: Native
iOS: mWeb Safari
MacOS: Chrome / Safari