diff --git a/.claude/skills/agent-device/flows/macros/android/complete-onboarding.ad b/.claude/skills/agent-device/flows/macros/android/complete-onboarding.ad
index c2b7b29ae6c5..39d5e35c849b 100644
--- a/.claude/skills/agent-device/flows/macros/android/complete-onboarding.ad
+++ b/.claude/skills/agent-device/flows/macros/android/complete-onboarding.ad
@@ -1,13 +1,14 @@
context platform=android
# @desc Complete onboarding with minimal choices (skip work email, pick "Something else" purpose, enter generic name). Lands on Home.
# @pre text="What’s your work email?"
-# @post text="Home"
# @post role="button" label="Search"
# @param FIRST_NAME First name to enter on onboarding profile step.
# @param LAST_NAME Last name to enter on onboarding profile step.
press "id=\"onboardingPrivateEmailSkipButton\" || role=\"button\" label=\"Skip\" || label=\"Skip\""
+wait "label=\"Something else\""
press "role=\"button\" label=\"Something else\" || label=\"Something else\""
+wait "label=\"First name\" editable=true"
fill "role=\"textfield\" label=\"First name\" editable=true || label=\"First name\" editable=true" "${FIRST_NAME}"
fill "role=\"textfield\" label=\"Last name\" editable=true || label=\"Last name\" editable=true" "${LAST_NAME}"
press "role=\"button\" label=\"Continue\" || label=\"Continue\""
diff --git a/.claude/skills/agent-device/flows/macros/android/sign-in.ad b/.claude/skills/agent-device/flows/macros/android/sign-in.ad
index b228982abc7e..157af9ff27a2 100644
--- a/.claude/skills/agent-device/flows/macros/android/sign-in.ad
+++ b/.claude/skills/agent-device/flows/macros/android/sign-in.ad
@@ -5,5 +5,6 @@ context platform=android
# @post role="button" label="Join" || role="button" label="Search"
# @param EMAIL Login email. Use randomized alias format `agent-device-testing+<9digits>@gmail.com` to avoid account flagging.
-fill "id=\"username\" || label=\"Phone or email\" editable=true" "${EMAIL}"
+press "id=\"username\" || role=\"edittext\" label=\"Phone or email\" editable=true || label=\"Phone or email\" editable=true"
+fill "id=\"username\" || role=\"edittext\" label=\"Phone or email\" editable=true || label=\"Phone or email\" editable=true" "${EMAIL}"
press "role=\"button\" label=\"Continue\" || label=\"Continue\""
diff --git a/.claude/skills/agent-device/flows/macros/web/complete-onboarding.ad b/.claude/skills/agent-device/flows/macros/web/complete-onboarding.ad
index f84ff00572b7..227bcd65cabd 100644
--- a/.claude/skills/agent-device/flows/macros/web/complete-onboarding.ad
+++ b/.claude/skills/agent-device/flows/macros/web/complete-onboarding.ad
@@ -1,7 +1,7 @@
context platform=web
# @desc Complete onboarding with minimal choices (skip work email, pick "Something else" purpose, enter generic name). Lands on Home.
# @pre text="What’s your work email?"
-# @post text="Home"
+# @post role="heading" label="Home"
# @post role="button" label="Search"
# @param FIRST_NAME First name to enter on onboarding profile step.
# @param LAST_NAME Last name to enter on onboarding profile step.
diff --git a/.claude/skills/agent-device/flows/macros/web/sign-in.ad b/.claude/skills/agent-device/flows/macros/web/sign-in.ad
index 93b9cd4b0499..6e55713f8df7 100644
--- a/.claude/skills/agent-device/flows/macros/web/sign-in.ad
+++ b/.claude/skills/agent-device/flows/macros/web/sign-in.ad
@@ -2,7 +2,7 @@ context platform=web
# @desc Sign in with the shared agent-device test account. Supports both new-account and returning-account outcomes. Caller MUST randomize EMAIL via `-e EMAIL=agent-device-testing+<9digits>@gmail.com` to avoid account flagging.
# @pre role="textbox" label="Phone or email"
# @pre role="button" label="Continue"
-# @post text="Welcome!" || text="Home"
+# @post text="Welcome!" || role="heading" label="Home"
# @post role="button" label="Join" || role="button" label="Search"
# @param EMAIL Login email. Use randomized alias format `agent-device-testing+<9digits>@gmail.com` to avoid account flagging.
diff --git a/.claude/skills/coding-standards/rules/perf-18-use-pre-mount-destination.md b/.claude/skills/coding-standards/rules/perf-18-use-pre-mount-destination.md
index 4f67ad417e8e..b33d34519d79 100644
--- a/.claude/skills/coding-standards/rules/perf-18-use-pre-mount-destination.md
+++ b/.claude/skills/coding-standards/rules/perf-18-use-pre-mount-destination.md
@@ -51,10 +51,22 @@ const handleSubmit = () => {
### Hook API
+**Layout strategies:**
+
+- `PRE_INSERT` (narrow layout only): eagerly pre-mounts the destination behind the RHP after the open transition, at idle priority.
+- `REVEAL`: skips eager pre-mount; the destination is inserted and revealed together when `reveal()` runs. This fallback is used:
+ - on wide layout always
+ - on narrow layout if pre-insert hasn't finished yet
+
**Reveal methods:**
-- `reveal(afterTransition?)`: if the hook owns a pre-inserted narrow route, clears the pre-insert flag and dismisses the RHP over that route. Otherwise, inserts the destination under the RHP and then dismisses it.
-- `cleanupPreMount()`: removes the owned pre-inserted destination before a back-out path closes the RHP without revealing the destination.
+- `reveal(afterTransition?)`: if the hook owns a pre-inserted route, clears the pre-insert flag and dismisses the RHP over that route. Otherwise, inserts the destination under the RHP then dismisses it.
+- `cleanupPreMount()`: removes the owned pre-inserted destination before a back-out path closes the RHP without revealing the destination. Safe to call unconditionally - no-ops if this instance never pre-inserted anything.
+
+**Other invariants:**
+
+- Only one component may own a pre-inserted route at a time. `reveal()` logs an alert if the global pre-insert flag is set by a different flow when it runs - a sign the previous owner didn't clean up.
+- When the destination resolves to one of the app's root tabs (Home, Inbox, Search, Settings, or Workspaces), pre-insert switches to that tab instead of pushing (`[Tab(A), RHP] -> [Tab(B), RHP]`), with the original tab saved for restore-on-cancel. For any other destination, it pushes a new route between the origin and the RHP (`[origin, RHP] -> [origin, destination, RHP]`). Determined by the destination route, not caller-configured.
**Caller responsibilities:**
@@ -89,11 +101,15 @@ Use `usePreMountDestination` when **all** of these are true:
### When NOT to use
+Pre-inserting is a real second screen mounted concurrently. Its effects run whether or not the user ever reveals it. If they back out, `cleanupPreMount()` removes the route but not the work that mount already triggered:
+
- Destination is not known in advance
- There is no modal/RHP to dismiss
- The destination is already the screen behind the modal
-- The transition is already fast enough. Profile first, do not add complexity speculatively
-- Flow-specific dismiss strategies that do not use pre-insert/reveal. Keep those helpers
+- The destination is heavy or rarely actually reached from this dismiss path. In these cases, pre-inserting on every open pays the concurrent-mount cost more often than it pays off
+- The transition is already fast enough on its own
+- The dismiss is not RHP-to-fullscreen with a destination known at mount time. Helpers like `dismissModalWithReport` and the strategies in `submitDismissStrategies.ts` cover shapes the hook does not model - RHP-to-RHP transitions, and destinations resolved at dismiss time - so they are not call sites waiting to be migrated
+- The caller is reaching for `shouldPreservePreInsertedRouteOnUnmount` to sidestep cleanup ordering. It exists only for the case where a genuinely different component finishes the dismiss after this one unmounts - anywhere else it leaves a pre-inserted route with no owner left to clean it up
### Review Metadata
@@ -104,6 +120,7 @@ Flag when:
- A caller relies on `reveal(afterTransition)` for work that must happen before navigation, such as validation, target-route selection, or a synchronous write needed before the destination is revealed
- A back-out path closes the RHP without calling `cleanupPreMount()` when the component owns a pre-inserted route
- A submit path unmounts the component before `reveal()` runs but does not preserve the pre-inserted route with `shouldPreservePreInsertedRouteOnUnmount`
+- `shouldPreservePreInsertedRouteOnUnmount` is passed without a clear reason a *different* component finishes the dismiss - if nothing else picks up the pre-insert, this just delays cleanup rather than serving its purpose
- New code reimplements pre-insert timing, back-out cleanup, or reveal-before-dismiss orchestration inline instead of using the hook, even if it avoids a direct `preInsertFullscreenUnderRHP` call
**DO NOT flag if:**
diff --git a/.github/workflows/androidBump.yml b/.github/workflows/androidBump.yml
index e9e2972ecc61..39aedc819b4b 100644
--- a/.github/workflows/androidBump.yml
+++ b/.github/workflows/androidBump.yml
@@ -10,7 +10,7 @@ jobs:
android_bump:
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Setup Node
uses: ./.github/actions/composite/setupNode
diff --git a/.github/workflows/authorChecklist.yml b/.github/workflows/authorChecklist.yml
index db53ec0b7cca..2603542b47f8 100644
--- a/.github/workflows/authorChecklist.yml
+++ b/.github/workflows/authorChecklist.yml
@@ -19,7 +19,7 @@ jobs:
&& github.actor != 'MelvinBot'
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Check contributor authorization
id: gate
diff --git a/.github/workflows/buildAdHoc.yml b/.github/workflows/buildAdHoc.yml
index d9303fcc4795..f1deafa80af9 100644
--- a/.github/workflows/buildAdHoc.yml
+++ b/.github/workflows/buildAdHoc.yml
@@ -141,7 +141,7 @@ jobs:
needs: [buildWeb, deployWebAdHoc, buildAndroid, buildIOS]
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
with:
ref: ${{ inputs.APP_REF }}
@@ -181,7 +181,7 @@ jobs:
needs: [buildWeb, deployWebAdHoc, buildAndroid, buildIOS]
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
with:
ref: ${{ inputs.APP_REF }}
diff --git a/.github/workflows/buildAndroid.yml b/.github/workflows/buildAndroid.yml
index 93455cbe92fd..684cdb64a5d4 100644
--- a/.github/workflows/buildAndroid.yml
+++ b/.github/workflows/buildAndroid.yml
@@ -63,7 +63,7 @@ jobs:
PROGUARD_MAPPING_FILENAME: mapping.txt
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
with:
submodules: true
ref: ${{ inputs.ref }}
diff --git a/.github/workflows/buildVictoryChartRenderer.yml b/.github/workflows/buildVictoryChartRenderer.yml
index 3f92f7b36f9e..868e8988d17b 100644
--- a/.github/workflows/buildVictoryChartRenderer.yml
+++ b/.github/workflows/buildVictoryChartRenderer.yml
@@ -25,7 +25,7 @@ jobs:
BINARY_FILENAME: victory-chart-renderer-linux-x64
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
with:
ref: ${{ inputs.ref }}
diff --git a/.github/workflows/buildWeb.yml b/.github/workflows/buildWeb.yml
index 33bd4f3b64c5..4506e2f6bec0 100644
--- a/.github/workflows/buildWeb.yml
+++ b/.github/workflows/buildWeb.yml
@@ -39,7 +39,7 @@ jobs:
PULL_REQUEST_NUMBER: ${{ inputs.pull-request-number }}
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
with:
ref: ${{ inputs.ref }}
diff --git a/.github/workflows/bunTests.yml b/.github/workflows/bunTests.yml
index 4d887f17ccd0..d32246c0c73d 100644
--- a/.github/workflows/bunTests.yml
+++ b/.github/workflows/bunTests.yml
@@ -34,7 +34,7 @@ jobs:
runs-on: blacksmith-8vcpu-ubuntu-2404
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Setup Node
uses: ./.github/actions/composite/setupNode
diff --git a/.github/workflows/checkSVGCompression.yml b/.github/workflows/checkSVGCompression.yml
index 0b5d9519edae..baa9526fcc94 100644
--- a/.github/workflows/checkSVGCompression.yml
+++ b/.github/workflows/checkSVGCompression.yml
@@ -21,7 +21,7 @@ jobs:
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Setup Node
uses: ./.github/actions/composite/setupNode
diff --git a/.github/workflows/checkValidateCodeTerminology.yml b/.github/workflows/checkValidateCodeTerminology.yml
index 83f017813dbd..5835bb2f06da 100644
--- a/.github/workflows/checkValidateCodeTerminology.yml
+++ b/.github/workflows/checkValidateCodeTerminology.yml
@@ -11,7 +11,7 @@ jobs:
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Check validateCode terminology
run: ./scripts/checkValidateCodeTerminology.sh
diff --git a/.github/workflows/cherryPick.yml b/.github/workflows/cherryPick.yml
index 0ecba2a0e892..098a2696e5af 100644
--- a/.github/workflows/cherryPick.yml
+++ b/.github/workflows/cherryPick.yml
@@ -101,7 +101,7 @@ jobs:
run: echo "CONFLICT_BRANCH_NAME=cherry-pick-${{ inputs.TARGET }}-${{ steps.getPRInfo.outputs.PR_NUMBER }}-${{ github.run_id }}-${{ github.run_attempt }}" >> "$GITHUB_OUTPUT"
- name: Checkout target branch
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
with:
ref: ${{ inputs.TARGET }}
token: ${{ secrets.OS_BOTIFY_TOKEN }}
diff --git a/.github/workflows/cla.yml b/.github/workflows/cla.yml
index 6f6507f63788..9a822b6158a4 100644
--- a/.github/workflows/cla.yml
+++ b/.github/workflows/cla.yml
@@ -15,7 +15,7 @@ jobs:
IS_AUTHORIZED: ${{ steps.gate.outputs.IS_AUTHORIZED }}
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Check contributor authorization
id: gate
diff --git a/.github/workflows/claude-review.yml b/.github/workflows/claude-review.yml
index 6e97e47af701..e3245f759eba 100644
--- a/.github/workflows/claude-review.yml
+++ b/.github/workflows/claude-review.yml
@@ -22,7 +22,7 @@ jobs:
PR_NUMBER: ${{ github.event.pull_request.number }}
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Check contributor authorization
id: gate
@@ -45,7 +45,7 @@ jobs:
- name: Checkout repository
if: steps.set-authorized.outputs.IS_AUTHORIZED == 'true'
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
with:
fetch-depth: 1
diff --git a/.github/workflows/createDeployChecklist.yml b/.github/workflows/createDeployChecklist.yml
index 6f4e0d55f38c..96f833c516f1 100644
--- a/.github/workflows/createDeployChecklist.yml
+++ b/.github/workflows/createDeployChecklist.yml
@@ -14,7 +14,7 @@ jobs:
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
with:
ref: ${{ inputs.REF || github.sha }}
diff --git a/.github/workflows/cspell.yml b/.github/workflows/cspell.yml
index c193fcf12af1..67a9591f0176 100644
--- a/.github/workflows/cspell.yml
+++ b/.github/workflows/cspell.yml
@@ -9,7 +9,7 @@ jobs:
spellcheck:
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Setup Node
uses: ./.github/actions/composite/setupNode
diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 38a6d507f108..c567befafc72 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -37,7 +37,7 @@ jobs:
IOS_VERSION: ${{ steps.getIOSVersion.outputs.IOS_VERSION }}
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
with:
ref: ${{ inputs.ENVIRONMENT || github.sha }}
token: ${{ secrets.OS_BOTIFY_TOKEN }}
@@ -215,7 +215,7 @@ jobs:
if: ${{ fromJSON(needs.prep.outputs.SHOULD_BUILD_NATIVE) }}
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
with:
ref: ${{ needs.prep.outputs.DEPLOY_SHA }}
@@ -269,7 +269,7 @@ jobs:
if: ${{ always() && !cancelled() && needs.prep.outputs.DEPLOY_ENV == 'production' && needs.androidBuild.result != 'failure' && needs.androidUploadGooglePlay.result != 'failure' }}
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
with:
ref: ${{ needs.prep.outputs.DEPLOY_SHA }}
@@ -705,7 +705,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
with:
ref: ${{ needs.prep.outputs.DEPLOY_SHA }}
@@ -767,7 +767,7 @@ jobs:
continue-on-error: true
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
with:
ref: ${{ needs.prep.outputs.DEPLOY_SHA }}
@@ -811,7 +811,7 @@ jobs:
]
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Post Slack message on failure
uses: ./.github/actions/composite/announceFailedWorkflowInSlack
@@ -948,7 +948,7 @@ jobs:
needs: [prep, checkDeploymentSuccess]
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Setup Node
uses: ./.github/actions/composite/setupNode
@@ -1123,7 +1123,7 @@ jobs:
SENTRY_URL: ${{ steps.sentry-upload.outputs.SENTRY_URL }}
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Upload to Sentry for size analysis
id: sentry-upload
@@ -1152,7 +1152,7 @@ jobs:
SENTRY_URL: ${{ steps.sentry-upload.outputs.SENTRY_URL }}
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Upload to Sentry for size analysis
id: sentry-upload
diff --git a/.github/workflows/deployBlocker.yml b/.github/workflows/deployBlocker.yml
index bcec004a56b4..3de8b9c5f68a 100644
--- a/.github/workflows/deployBlocker.yml
+++ b/.github/workflows/deployBlocker.yml
@@ -16,7 +16,7 @@ jobs:
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Give the issue/PR the Hourly, Engineering labels
run: gh issue edit ${{ github.event.issue.number }} --add-label 'Engineering,Hourly' --remove-label 'Daily,Weekly,Monthly'
diff --git a/.github/workflows/deployExpensifyHelp.yml b/.github/workflows/deployExpensifyHelp.yml
index ffaf22b59c2c..8979988505b0 100644
--- a/.github/workflows/deployExpensifyHelp.yml
+++ b/.github/workflows/deployExpensifyHelp.yml
@@ -33,7 +33,7 @@ jobs:
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
with:
fetch-depth: 0
diff --git a/.github/workflows/failureNotifier.yml b/.github/workflows/failureNotifier.yml
index 46f9902903da..0ebfc617a293 100644
--- a/.github/workflows/failureNotifier.yml
+++ b/.github/workflows/failureNotifier.yml
@@ -16,7 +16,7 @@ jobs:
if: ${{ github.event.workflow_run.conclusion == 'failure' }}
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Process Failed Jobs
uses: ./.github/actions/javascript/failureNotifier
diff --git a/.github/workflows/finishReleaseCycle.yml b/.github/workflows/finishReleaseCycle.yml
index 29813be60d65..b7880d77c08b 100644
--- a/.github/workflows/finishReleaseCycle.yml
+++ b/.github/workflows/finishReleaseCycle.yml
@@ -13,7 +13,7 @@ jobs:
isValid: ${{ fromJSON(steps.isDeployer.outputs.IS_DEPLOYER) && !fromJSON(steps.checkDeployBlockers.outputs.HAS_DEPLOY_BLOCKERS) && (contains(github.event.issue.labels.*.name, 'ForceProductionDeploy') || fromJSON(steps.verifyStagingBuilds.outputs.ALL_NATIVE_BUILDS_SUCCEEDED || 'false')) }}
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
with:
ref: main
token: ${{ secrets.OS_BOTIFY_TOKEN }}
diff --git a/.github/workflows/formatCodeCovComment.yml b/.github/workflows/formatCodeCovComment.yml
index 34b8fa95665f..ee379d83a216 100644
--- a/.github/workflows/formatCodeCovComment.yml
+++ b/.github/workflows/formatCodeCovComment.yml
@@ -13,7 +13,7 @@ jobs:
if: github.event.issue.pull_request && github.event.comment.user.login == 'codecov[bot]'
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Format CodeCov Comment
uses: ./.github/actions/javascript/formatCodeCovComment
diff --git a/.github/workflows/generateTranslations.yml b/.github/workflows/generateTranslations.yml
index 06f79683b0de..b3109fc9f83e 100644
--- a/.github/workflows/generateTranslations.yml
+++ b/.github/workflows/generateTranslations.yml
@@ -42,7 +42,7 @@ jobs:
GITHUB_TOKEN: ${{ github.token }}
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
with:
ref: ${{ steps.pr-data.outputs.HEAD_SHA }}
diff --git a/.github/workflows/knip.yml b/.github/workflows/knip.yml
index 6d51d65b4686..92fe004e6cb9 100644
--- a/.github/workflows/knip.yml
+++ b/.github/workflows/knip.yml
@@ -27,7 +27,7 @@ jobs:
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout PR
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Setup Node
uses: ./.github/actions/composite/setupNode
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index f664e686c845..72cbf457423c 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -36,7 +36,7 @@ jobs:
runs-on: blacksmith-16vcpu-ubuntu-2404
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
with:
# Only use the elevated OSBotify token on the post-merge `workflow_call` run
# (so the auto-commit step below can push to the protected `main` branch).
diff --git a/.github/workflows/lockDeploys.yml b/.github/workflows/lockDeploys.yml
index 58e30ad59434..884b4e51f157 100644
--- a/.github/workflows/lockDeploys.yml
+++ b/.github/workflows/lockDeploys.yml
@@ -10,7 +10,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Wait for staging deploys to finish
uses: ./.github/actions/javascript/awaitStagingDeploys
diff --git a/.github/workflows/oxfmt.yml b/.github/workflows/oxfmt.yml
index a050f8dbdaa7..93d5e4fbf335 100644
--- a/.github/workflows/oxfmt.yml
+++ b/.github/workflows/oxfmt.yml
@@ -18,7 +18,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Setup Node
uses: ./.github/actions/composite/setupNode
diff --git a/.github/workflows/postDeployComments.yml b/.github/workflows/postDeployComments.yml
index ec76d1a62175..6778eaedf38f 100644
--- a/.github/workflows/postDeployComments.yml
+++ b/.github/workflows/postDeployComments.yml
@@ -89,7 +89,7 @@ jobs:
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Setup Node
uses: ./.github/actions/composite/setupNode
diff --git a/.github/workflows/preDeploy.yml b/.github/workflows/preDeploy.yml
index f61b057e0f0b..0c09e86b3098 100644
--- a/.github/workflows/preDeploy.yml
+++ b/.github/workflows/preDeploy.yml
@@ -30,7 +30,7 @@ jobs:
if: ${{ always() }}
steps:
- - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Exit failed workflow
if: ${{ needs.typecheck.result == 'failure' || needs.lint.result == 'failure' || needs.test.result == 'failure' || needs.bunTests.result == 'failure' }}
@@ -46,7 +46,7 @@ jobs:
SHOULD_DEPLOY: ${{ fromJSON(steps.shouldDeploy.outputs.SHOULD_DEPLOY) }}
steps:
- - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Get merged pull request
id: getMergedPullRequest
diff --git a/.github/workflows/proposalPolice.yml b/.github/workflows/proposalPolice.yml
index a81b72c4049f..8992fc4fface 100644
--- a/.github/workflows/proposalPolice.yml
+++ b/.github/workflows/proposalPolice.yml
@@ -43,7 +43,7 @@ jobs:
echo "TRUSTED=${TRUSTED}" >> "$GITHUB_OUTPUT"
- - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
# Classifies new comments that don't follow the proposal template, detects duplicate proposals,
# and grades edits to existing proposals. Action type logic can be found in the script files.
diff --git a/.github/workflows/publishReactNativeAndroidArtifacts.yml b/.github/workflows/publishReactNativeAndroidArtifacts.yml
index af3c9c82e290..8048abe07e4d 100644
--- a/.github/workflows/publishReactNativeAndroidArtifacts.yml
+++ b/.github/workflows/publishReactNativeAndroidArtifacts.yml
@@ -40,7 +40,7 @@ jobs:
cancel-in-progress: true
steps:
- name: Checkout Code
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
with:
ref: ${{ inputs.app_ref }}
submodules: ${{ matrix.is_hybrid }}
diff --git a/.github/workflows/publishReactNativeArtifacts.yml b/.github/workflows/publishReactNativeArtifacts.yml
index 2686feb91160..50edbcdb48d7 100644
--- a/.github/workflows/publishReactNativeArtifacts.yml
+++ b/.github/workflows/publishReactNativeArtifacts.yml
@@ -51,8 +51,8 @@ jobs:
build_targets: ${{ steps.getArtifactBuildTargets.outputs.BUILD_TARGETS }}
steps:
- name: Checkout
- # v1.6.0
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815
+ # v1.7.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056
with:
submodules: true
ref: ${{ github.event.before || 'main' }}
diff --git a/.github/workflows/publishReactNativeiOSArtifacts.yml b/.github/workflows/publishReactNativeiOSArtifacts.yml
index 0a32c5b6aebe..56aebc1d2322 100644
--- a/.github/workflows/publishReactNativeiOSArtifacts.yml
+++ b/.github/workflows/publishReactNativeiOSArtifacts.yml
@@ -190,7 +190,7 @@ jobs:
cancel-in-progress: false
steps:
- name: Checkout Code
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
with:
ref: ${{ inputs.app_ref }}
submodules: ${{ matrix.is_hybrid }}
diff --git a/.github/workflows/react-compiler-compliance.yml b/.github/workflows/react-compiler-compliance.yml
index b3d7ae302f68..46b1db684387 100644
--- a/.github/workflows/react-compiler-compliance.yml
+++ b/.github/workflows/react-compiler-compliance.yml
@@ -18,7 +18,7 @@ jobs:
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Setup Node
uses: ./.github/actions/composite/setupNode
diff --git a/.github/workflows/reassurePerformanceTests.yml b/.github/workflows/reassurePerformanceTests.yml
index 0888a6c0eaae..422ffc782c75 100644
--- a/.github/workflows/reassurePerformanceTests.yml
+++ b/.github/workflows/reassurePerformanceTests.yml
@@ -18,7 +18,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout baseline branch
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Checkout baseline branch
shell: bash
@@ -80,7 +80,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Setup NodeJS
uses: ./.github/actions/composite/setupNode
@@ -137,7 +137,7 @@ jobs:
needs: [baseline-perf-tests, branch-perf-tests]
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Setup NodeJS
uses: ./.github/actions/composite/setupNode
diff --git a/.github/workflows/remote-build-android.yml b/.github/workflows/remote-build-android.yml
index 07e00602e59f..6bf7b31b06fa 100644
--- a/.github/workflows/remote-build-android.yml
+++ b/.github/workflows/remote-build-android.yml
@@ -57,7 +57,7 @@ jobs:
is_hybrid_build: true
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
with:
ref: ${{ needs.resolveRefs.outputs.APP_REF }}
submodules: ${{ matrix.is_hybrid_build || false }}
diff --git a/.github/workflows/reviewerChecklist.yml b/.github/workflows/reviewerChecklist.yml
index 4f07c450dcce..e85b8bfe5b49 100644
--- a/.github/workflows/reviewerChecklist.yml
+++ b/.github/workflows/reviewerChecklist.yml
@@ -9,7 +9,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
if: github.actor != 'OSBotify' && github.actor != 'imgbot[bot]'
steps:
- - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Filter paths
uses: dorny/paths-filter@fbd0ab8f3e69293af611ebaee6363fc25e6d187d # v4.0.1
diff --git a/.github/workflows/seedCache.yml b/.github/workflows/seedCache.yml
index 8b9ae9a57d8a..ae6a9472c29e 100644
--- a/.github/workflows/seedCache.yml
+++ b/.github/workflows/seedCache.yml
@@ -15,7 +15,7 @@ jobs:
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Setup Node (seed)
uses: ./.github/actions/composite/setupNode
@@ -27,7 +27,7 @@ jobs:
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
with:
submodules: true
token: ${{ secrets.OS_BOTIFY_TOKEN }}
diff --git a/.github/workflows/seedJestPerfCache.yml b/.github/workflows/seedJestPerfCache.yml
index f4b63706f26d..02f62bc64000 100644
--- a/.github/workflows/seedJestPerfCache.yml
+++ b/.github/workflows/seedJestPerfCache.yml
@@ -22,7 +22,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
- uses: useblacksmith/checkout@1c9394c220d293645707b625ba9d79685f093a8f # v1
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
# Writes normalized-package-lock.json, which the cache key hashes.
- name: Setup NodeJS
diff --git a/.github/workflows/shellCheck.yml b/.github/workflows/shellCheck.yml
index 8be310351d8d..871e5ab42124 100644
--- a/.github/workflows/shellCheck.yml
+++ b/.github/workflows/shellCheck.yml
@@ -13,7 +13,7 @@ jobs:
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Lint shell scripts with ShellCheck
run: npm run shellcheck
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index 8f19cf4b1d5d..163a97c69349 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -25,7 +25,7 @@ jobs:
name: test (job ${{ fromJSON(matrix.chunk) }})
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Setup Node
uses: ./.github/actions/composite/setupNode
@@ -87,7 +87,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
name: Storybook tests
steps:
- - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- uses: ./.github/actions/composite/setupNode
diff --git a/.github/workflows/testBuildOnPush.yml b/.github/workflows/testBuildOnPush.yml
index 328dff653d13..b7f3d20e3d0b 100644
--- a/.github/workflows/testBuildOnPush.yml
+++ b/.github/workflows/testBuildOnPush.yml
@@ -20,7 +20,7 @@ jobs:
BUILD_MOBILE: ${{ steps.detectOSBotifyPush.outputs.BUILD_MOBILE || 'true' }}
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Validate that user is an Expensify employee
uses: ./.github/actions/composite/validateActor
diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml
index a4bc0de4cb28..dce010bd5bc2 100644
--- a/.github/workflows/typecheck.yml
+++ b/.github/workflows/typecheck.yml
@@ -16,7 +16,7 @@ jobs:
if: ${{ github.actor != 'OSBotify' || github.event_name == 'workflow_call' }}
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- uses: ./.github/actions/composite/setupNode
diff --git a/.github/workflows/unused-styles.yml b/.github/workflows/unused-styles.yml
index 26b44fb6a45e..03afa2affa95 100644
--- a/.github/workflows/unused-styles.yml
+++ b/.github/workflows/unused-styles.yml
@@ -18,7 +18,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Setup Node
uses: ./.github/actions/composite/setupNode
diff --git a/.github/workflows/updateHelpDotRedirects.yml b/.github/workflows/updateHelpDotRedirects.yml
index 76a1ecde942f..8026529e7abe 100644
--- a/.github/workflows/updateHelpDotRedirects.yml
+++ b/.github/workflows/updateHelpDotRedirects.yml
@@ -22,7 +22,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Create help dot redirect
env:
diff --git a/.github/workflows/updateProtectedBranch.yml b/.github/workflows/updateProtectedBranch.yml
index caf0eb3b7dc0..869ef5152d2a 100644
--- a/.github/workflows/updateProtectedBranch.yml
+++ b/.github/workflows/updateProtectedBranch.yml
@@ -28,7 +28,7 @@ jobs:
fi
- name: Checkout source branch
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
with:
ref: ${{ steps.getSourceBranch.outputs.SOURCE_BRANCH }}
token: ${{ secrets.OS_BOTIFY_TOKEN }}
diff --git a/.github/workflows/validateBuildRequest.yml b/.github/workflows/validateBuildRequest.yml
index 4f43ffa56f8e..d212bf02f818 100644
--- a/.github/workflows/validateBuildRequest.yml
+++ b/.github/workflows/validateBuildRequest.yml
@@ -27,7 +27,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Validate that user is an Expensify employee
if: ${{ github.event_name == 'workflow_dispatch' }}
diff --git a/.github/workflows/validateContributorPR.yml b/.github/workflows/validateContributorPR.yml
index 109ff07ac7c4..7b1866355a3d 100644
--- a/.github/workflows/validateContributorPR.yml
+++ b/.github/workflows/validateContributorPR.yml
@@ -18,7 +18,7 @@ jobs:
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Check contributor authorization
id: gate
diff --git a/.github/workflows/validateDocsRoutes.yml b/.github/workflows/validateDocsRoutes.yml
index 0f4fc1772dd4..0e9ba202003d 100644
--- a/.github/workflows/validateDocsRoutes.yml
+++ b/.github/workflows/validateDocsRoutes.yml
@@ -11,7 +11,7 @@ jobs:
if: github.actor != 'OSBotify' && github.actor != 'imgbot[bot]'
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- uses: ./.github/actions/composite/setupNode
diff --git a/.github/workflows/validateGithubActions.yml b/.github/workflows/validateGithubActions.yml
index 4147a45ee628..4d4f9b5ef9fa 100644
--- a/.github/workflows/validateGithubActions.yml
+++ b/.github/workflows/validateGithubActions.yml
@@ -12,7 +12,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Setup Node
uses: ./.github/actions/composite/setupNode
diff --git a/.github/workflows/validateMobileExpensifySubmodule.yml b/.github/workflows/validateMobileExpensifySubmodule.yml
index 6b16ae9fb548..95ee53aef1fb 100644
--- a/.github/workflows/validateMobileExpensifySubmodule.yml
+++ b/.github/workflows/validateMobileExpensifySubmodule.yml
@@ -13,10 +13,10 @@ jobs:
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- name: Checkout App
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Checkout Mobile-Expensify
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
with:
repository: Expensify/Mobile-Expensify
path: .github/mobile-expensify-repo
diff --git a/.github/workflows/validatePatches.yml b/.github/workflows/validatePatches.yml
index 1390979a7abf..20f23630742f 100644
--- a/.github/workflows/validatePatches.yml
+++ b/.github/workflows/validatePatches.yml
@@ -11,7 +11,7 @@ jobs:
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Fetch main branch
run: git fetch origin --depth=1 main
diff --git a/.github/workflows/verifySignedCommits.yml b/.github/workflows/verifySignedCommits.yml
index b5b963bf227b..6f6b8d10559d 100644
--- a/.github/workflows/verifySignedCommits.yml
+++ b/.github/workflows/verifySignedCommits.yml
@@ -9,7 +9,7 @@ jobs:
verifySignedCommits:
runs-on: blacksmith-2vcpu-ubuntu-2404
steps:
- - uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ - uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Verify signed commits
uses: ./.github/actions/javascript/verifySignedCommits
diff --git a/.github/workflows/welcome.yml b/.github/workflows/welcome.yml
index c0cd43749f74..be82bc3787c2 100644
--- a/.github/workflows/welcome.yml
+++ b/.github/workflows/welcome.yml
@@ -10,7 +10,7 @@ jobs:
if: ${{ github.actor != 'OSBotify' && github.actor != 'imgbot[bot]' }}
steps:
- name: Checkout
- uses: useblacksmith/checkout@f50565632710173b0e366f2cd39b928d31362815 # v1.6.0
+ uses: useblacksmith/checkout@0647fdbab2614a5eb86d2971070e325060d91056 # v1.7.0
- name: Get merged pull request
id: getMergedPullRequest
diff --git a/Mobile-Expensify b/Mobile-Expensify
index 57708e6b576b..941700ccc01f 160000
--- a/Mobile-Expensify
+++ b/Mobile-Expensify
@@ -1 +1 @@
-Subproject commit 57708e6b576bfb38ca22dc596f0c4b47a22d6319
+Subproject commit 941700ccc01fccadfab499655c760661892168d6
diff --git a/android/app/build.gradle b/android/app/build.gradle
index 8e9f05e06202..23fa0efc9ce8 100644
--- a/android/app/build.gradle
+++ b/android/app/build.gradle
@@ -111,8 +111,8 @@ android {
minSdkVersion rootProject.ext.minSdkVersion
targetSdkVersion rootProject.ext.targetSdkVersion
multiDexEnabled rootProject.ext.multiDexEnabled
- versionCode 1009047400
- versionName "9.4.74-0"
+ versionCode 1009047500
+ versionName "9.4.75-0"
// Supported language variants must be declared here to avoid from being removed during the compilation.
// This also helps us to not include unnecessary language variants in the APK.
resConfigs "en", "es"
diff --git a/config/eslint/eslint.config.mjs b/config/eslint/eslint.config.mjs
index 09f3ca7d01a1..d6c5a9574268 100644
--- a/config/eslint/eslint.config.mjs
+++ b/config/eslint/eslint.config.mjs
@@ -43,6 +43,18 @@ const localRulesDir = path.resolve(projectRoot, 'eslint-plugin-local-rules');
rulesdir.RULES_DIR = [expensifyRulesDir, localRulesDir];
const restrictedImportPaths = [
+ {
+ name: '@components/FlatList',
+ message: "Use 'LegendList' from '@legendapp/list/react-native' instead.",
+ },
+ {
+ name: '@components/KeyboardDismissibleFlatList',
+ message: "Use 'LegendList' from '@legendapp/list/react-native' instead.",
+ },
+ {
+ name: '@components/KeyboardDismissibleFlatList/index',
+ message: "Use 'LegendList' from '@legendapp/list/react-native' instead.",
+ },
{
name: '@components/Button',
importNames: ['default'],
@@ -65,6 +77,8 @@ const restrictedImportPaths = [
'Pressable',
'Text',
'ScrollView',
+ 'FlatList',
+ 'FlatListProps',
'ActivityIndicator',
'Animated',
'findNodeHandle',
@@ -77,6 +91,7 @@ const restrictedImportPaths = [
"For 'StatusBar', please use '@libs/StatusBar' instead.",
"For 'Text', please use '@components/Text' instead.",
"For 'ScrollView', please use '@components/ScrollView' instead.",
+ "For 'FlatList' and 'FlatListProps', please use 'LegendList' and 'LegendListProps' from '@legendapp/list/react-native' instead.",
"For 'ActivityIndicator', please use '@components/ActivityIndicator' instead.",
"For 'Animated', please use 'Animated' from 'react-native-reanimated' instead.",
"For 'InteractionManager', please use afterTransition callbacks on Navigation/KeyboardUtils or other alternatives. See contributingGuides/INTERACTION_MANAGER.md.",
@@ -84,8 +99,9 @@ const restrictedImportPaths = [
},
{
name: 'react-native-gesture-handler',
- importNames: ['TouchableOpacity', 'TouchableWithoutFeedback', 'TouchableNativeFeedback', 'TouchableHighlight'],
- message: "Please use 'PressableWithFeedback' and/or 'PressableWithoutFeedback' from '@components/Pressable' instead.",
+ importNames: ['TouchableOpacity', 'TouchableWithoutFeedback', 'TouchableNativeFeedback', 'TouchableHighlight', 'FlatList', 'FlatListProps'],
+ message:
+ "Use 'LegendList' from '@legendapp/list/react-native' for lists, and 'PressableWithFeedback' and/or 'PressableWithoutFeedback' from '@components/Pressable' for pressable components.",
},
{
name: 'awesome-phonenumber',
@@ -170,6 +186,22 @@ const restrictedImportPaths = [
];
const restrictedImportPatterns = [
+ {
+ group: [
+ '@shopify/flash-list',
+ '@shopify/flash-list/**',
+ 'react-native-draggable-flatlist',
+ 'react-native-draggable-flatlist/**',
+ 'react-native/Libraries/Lists/FlatList*',
+ 'react-native-web/dist/exports/FlatList',
+ 'react-native-web/dist/cjs/exports/FlatList',
+ ],
+ message: "Use 'LegendList' from '@legendapp/list/react-native' for lists or '@components/DraggableList' for draggable lists.",
+ },
+ {
+ group: ['@components/FlatList/FlatList', '@components/FlatList/FlatList/**', '@components/FlashList', '@components/FlashList/**'],
+ message: "Legacy list wrappers have been removed. Use 'LegendList' from '@legendapp/list/react-native' instead.",
+ },
{
group: ['**/assets/animations/**/*.json'],
message: "Do not import animations directly. Please use the '@components/LottieAnimations' import instead.",
@@ -391,6 +423,10 @@ const config = defineConfig([
},
// These are the original rules from AirBnB's style guide, modified to allow for...of loops and for...in loops
+ {
+ selector: 'JSXMemberExpression[property.name=/^(FlatList|FlashList)$/]',
+ message: "Use 'AnimatedLegendList' from '@legendapp/list/reanimated' for animated lists or 'LegendList' from '@legendapp/list/react-native' for plain lists.",
+ },
{
selector: 'LabeledStatement',
message: 'Labels are a form of GOTO; using them makes code confusing and hard to maintain and understand.',
@@ -402,6 +438,14 @@ const config = defineConfig([
],
'no-restricted-properties': [
'error',
+ {
+ property: 'FlatList',
+ message: "Use 'AnimatedLegendList' from '@legendapp/list/reanimated' for animated lists or 'LegendList' from '@legendapp/list/react-native' for plain lists.",
+ },
+ {
+ property: 'FlashList',
+ message: "Use 'LegendList' from '@legendapp/list/react-native' instead.",
+ },
{
object: 'Image',
property: 'getSize',
diff --git a/config/eslint/eslint.seatbelt.tsv b/config/eslint/eslint.seatbelt.tsv
index 17c05c300f51..b0fb8fac8a7d 100644
--- a/config/eslint/eslint.seatbelt.tsv
+++ b/config/eslint/eslint.seatbelt.tsv
@@ -1207,7 +1207,6 @@
"../../src/pages/workspace/accounting/xero/XeroTrackingCategoryConfigurationPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/pages/workspace/categories/CategorySettingsPage.tsx" "react-hooks/preserve-manual-memoization" 1
"../../src/pages/workspace/categories/WorkspaceCategoriesPage.tsx" "react-hooks/set-state-in-effect" 1
-"../../src/pages/workspace/companyCards/addNew/DynamicAddNewCardPage.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 1
"../../src/pages/workspace/companyCards/addNew/SelectCountryStep.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/pages/workspace/companyCards/BankConnection/index.tsx" "react-hooks/set-state-in-effect" 1
"../../src/pages/workspace/companyCards/DynamicWorkspaceCompanyCardDetailsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
@@ -1247,7 +1246,6 @@
"../../src/pages/workspace/tags/WorkspaceTagsPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3
"../../src/pages/workspace/tags/WorkspaceTagsPage.tsx" "react-hooks/set-state-in-effect" 1
"../../src/pages/workspace/taxes/WorkspaceTaxesPage.tsx" "react-hooks/set-state-in-effect" 1
-"../../src/pages/workspace/travel/WorkspaceTravelBillingSection.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 3
"../../src/pages/workspace/travel/WorkspaceTravelBillingSettlementAccountPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/pages/workspace/upgrade/UpgradeIntro.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/pages/workspace/upgrade/WorkspaceUpgradePage.tsx" "no-restricted-imports" 1
@@ -1257,7 +1255,6 @@
"../../src/pages/workspace/workflows/tabs/WorkflowsPaymentsTab.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/pages/workspace/workflows/WorkspaceAutoReportingFrequencyPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 2
"../../src/pages/workspace/workflows/WorkspaceAutoReportingMonthlyOffsetPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
-"../../src/pages/workspace/workflows/WorkspaceWorkflowsPayerPage.tsx" "@typescript-eslint/no-deprecated/ConfirmModal" 2
"../../src/pages/workspace/WorkspaceMembersPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 1
"../../src/pages/workspace/WorkspaceMembersPage.tsx" "no-restricted-imports" 1
"../../src/pages/workspace/WorkspaceNewRoomPage.tsx" "@typescript-eslint/no-unsafe-type-assertion" 3
diff --git a/contributingGuides/NAVIGATION.md b/contributingGuides/NAVIGATION.md
index 661f4e211e87..9974de382c19 100644
--- a/contributingGuides/NAVIGATION.md
+++ b/contributingGuides/NAVIGATION.md
@@ -11,6 +11,8 @@ The navigation in the app is built on top of the `react-navigation` library. To
- [Going back](#going-back)
- [Dismissing modals](#dismissing-modals)
- [Dismissing modals with opening a report](#dismissing-modals-with-opening-a-report)
+ - [Pre-mounting a destination behind an RHP](#pre-mounting-a-destination-behind-an-rhp)
+ - [What pre-inserting actually costs](#what-pre-inserting-actually-costs)
- [Summary](#summary)
- [Adding new screens](#adding-new-screens)
- [Multi-step flows with URL synchronization](#multi-step-flows-with-url-synchronization)
@@ -251,6 +253,59 @@ Navigation.dismissModalWithReport({
> 1. On a narrow screen, we do not want to perform two operations: closing the modal and opening the report. This would cause two actions to be displayed on the screen, which could be confusing for users. Instead of two operations, we perform a replace on the modal, thanks to which there is a smooth transition to the report with simultaneous closing of the modal.
> 2. On a wide screen, we need to be sure that the modal has been closed before we want to navigate to the report. For this purpose, `navigate` is passed as the `afterTransition` callback to `dismissModal`, so it only runs once the dismiss transition has completed (tracked via `TransitionTracker`).
+### Pre-mounting a destination behind an RHP
+
+When dismissing an RHP reveals a **different** fullscreen destination (not the screen already behind it - see [Dismissing modals with opening a report](#dismissing-modals-with-opening-a-report) for that case), the destination needs to be mounted before the dismissal reveals it. Otherwise there's a visible gap on narrow layout, or a flash of the previous page on wide layout, while React mounts the destination tree.
+
+`usePreMountDestination` (`src/hooks/usePreMountDestination`) centralizes this lifecycle. It has two layout-specific strategies:
+
+- **Narrow layout (default, `narrowDestinationStrategy: CONST.NARROW_DESTINATION_STRATEGY.PRE_INSERT`):** on mount, waits for the RHP's open transition, then pre-inserts the destination route underneath the RHP at idle priority (`preInsertFullscreenUnderRHP`). By the time the user dismisses, the destination is already mounted, so dismissal just reveals it.
+- **Narrow layout with `narrowDestinationStrategy: CONST.NARROW_DESTINATION_STRATEGY.REVEAL`, or wide layout (always), or narrow layout where the pre-insert hasn't finished yet:** `reveal()` calls `Navigation.revealRouteBeforeDismissingModal` instead - it swaps in the destination and dismisses in one step, at reveal time rather than eagerly. Correctness is the same either way; only the narrow pre-insert path has the mount-ahead-of-time perf win.
+
+```tsx
+const destinationRoute = buildDestinationRoute(itemID);
+const {reveal, cleanupPreMount} = usePreMountDestination(destinationRoute);
+
+const handleSubmit = () => {
+ saveDataRequiredByDestination(); // synchronous work happens before reveal()
+ reveal();
+};
+
+const handleBackOut = () => {
+ cleanupPreMount(); // safe to call unconditionally - no-ops if nothing was pre-inserted
+ Navigation.goBack();
+};
+```
+
+See `IOURequestStepConfirmation.tsx` for a reference implementation.
+
+- `reveal(afterTransition?)`: dismisses the RHP over the pre-inserted destination if the hook owns one, otherwise falls back to `revealRouteBeforeDismissingModal` (see above).
+- `cleanupPreMount()`: removes the owned pre-insert, if any. Call it unconditionally on every back-out path (header back, hardware back) that closes the RHP without calling `reveal()` - it's a no-op when this instance never actually pre-inserted anything.
+- `shouldPreservePreInsertedRouteOnUnmount`: pass when the component unmounts before `reveal()` runs but the pre-insert should survive (e.g. the caller dismisses separately after a submit).
+
+> [!NOTE]
+> Only one component may own a pre-inserted route at a time. `reveal()` logs an alert if it runs while a *different* flow's pre-insert flag is still set - that's a sign the previous owner didn't clean up.
+
+> [!NOTE]
+> When the destination resolves to one of the app's root tabs (Home, Inbox, Search, Settings, or Workspaces), pre-insert switches to that tab instead of pushing (`[Tab(A), RHP] -> [Tab(B), RHP]`), with the original tab saved for restore-on-cancel. For any other destination, it pushes a new route between the origin and the RHP instead (`[origin, RHP] -> [origin, destination, RHP]`). Which one happens is determined by the destination route, not by anything the caller configures.
+
+> [!NOTE]
+> See [PERF-18](../.claude/skills/coding-standards/rules/perf-18-use-pre-mount-destination.md) for the AI-review checklist covering this hook.
+
+#### What pre-inserting actually costs
+
+Pre-inserting is not a lightweight placeholder swap - it's a real second screen mounted in the stack, with its own Onyx connections, effects, and any API calls it fires on mount, running concurrently with the RHP that's still open. That mount happens whether or not the user ever reveals it: if they back out, `cleanupPreMount()` removes the *route*, but any data fetch the destination's mount already triggered already ran. For a screen with meaningful data-fetching behind it, that's wasted work on every back-out, not a free perf win.
+
+This is why the hook's own precondition matters and isn't just a checkbox: the destination has to be known at mount time, otherwise there's nothing correct to pre-insert. Dwell time is not a precondition - the pre-insert waits for the RHP's open transition and then schedules at idle priority, and if the user dismisses before that fires, the unmount cleanup cancels it and nothing was mounted.
+
+Native swipe-back can pop the RHP at the native layer before any JS cleanup runs, briefly flashing the pre-inserted destination. This is a known limitation without a deterministic fix yet.
+
+**Examples when NOT to use `usePreMountDestination`:**
+
+- **The destination is heavy and rarely reached from the dismiss path.** If most users dismiss rather than actually submitting, pre-inserting on every RHP open pays the concurrent-mount cost far more often than it pays off. Search logs to profile the production dismiss-to-reveal ratio before assuming pre-insert wins.
+- **The dismiss isn't RHP-to-fullscreen with a destination known at mount time.** `dismissModalWithReport` and the strategies in `submitDismissStrategies.ts` handle shapes this hook doesn't model: RHP-to-RHP transitions (`dismissToPreviousRHP`), and destinations resolved at dismiss time (`dismissRHPToReport` branches on `report.transactionCount` when the user submits). Converging those on the hook would mean growing the hook to cover them, not migrating call sites.
+- **You're tempted to use `shouldPreservePreInsertedRouteOnUnmount` as a default escape hatch.** It exists for one narrow case: a *different* caller finishes the dismiss after this component unmounts. Anywhere else it leaves a pre-inserted route with no owner left to clean it up, so the next flow's `reveal()` runs into an already-set pre-insert flag.
+
### Summary
- `Navigation.navigate` is used to navigate between screens. Remember that it calls the `linkTo` method implemented by us. It accepts the route as a parameter not a screen name.
@@ -258,6 +313,7 @@ Navigation.dismissModalWithReport({
- If you want to go back to the screen regardless of its parameter values, pass `{compareParams: false}` to `Navigation.goBack`.
- If you want to close the entire modal window, regardless of how many pages you have opened, use `Navigation.dismissModal` to do that.
- If you want to open a report from RHP to prevent navigation back to this modal window, use `Navigation.dismissModalWithReport`.
+- If dismissing an RHP reveals a different fullscreen destination, use `usePreMountDestination` to mount it ahead of time - but only when the destination is known at mount time; it's a real concurrent mount, not a free perf win, so profile before reaching for it on a heavy or rarely-reached destination.
## Adding new screens
diff --git a/cspell.json b/cspell.json
index 786c2758735a..107bc4d52591 100644
--- a/cspell.json
+++ b/cspell.json
@@ -568,6 +568,7 @@
"cpuprofile",
"creditamount",
"creditcards",
+ "creditedamount",
"crios",
"csvexport",
"csvg",
@@ -581,6 +582,7 @@
"dgst",
"deapexer",
"debitamount",
+ "debitedamount",
"deburr",
"deburred",
"decidium",
diff --git a/docs/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports.md b/docs/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports.md
index 2226e6b24d74..b2d85427ed10 100644
--- a/docs/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports.md
+++ b/docs/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports.md
@@ -94,6 +94,10 @@ Enter any of the following formulas into the Formula field for each column. Be s
| {report:type} | Would output "Expense Report" assuming that is the report's type.|
| Reimbursable Total | Is the total amount that is reimbursable on the report.|
| {report:reimbursable} | Would output $143.43 assuming the report's reimbursable total was 143.43 US Dollars.|
+| Debited Amount | Amount taken from the company bank account when the report was reimbursed across currencies. Empty if the report is not a cross-border reimbursement.|
+| {report:debitedAmount} | Would output $82.50 assuming that is what the company was debited.|
+| Credited Amount | Amount deposited to the employee bank account when the report was reimbursed across currencies. Empty if the report is not a cross-border reimbursement.|
+| {report:creditedAmount} | Would output C$110.00 assuming that is what the employee received.|
| Currency | Is the currency to which all expenses on the report are being converted.|
| {report:currency} | Would output USD assuming that the report total was calculated in US dollars.|
|| Note - Currency accepts an optional three character currency code or NONE. If you want to do any math operations on the report total, you should use {report:total:nosymbol} to avoid an error. Please see Expense:Amount for more information on currencies.|
diff --git a/docs/articles/new-expensify/connections/dualentry/_meta.yml b/docs/articles/new-expensify/connections/dualentry/_meta.yml
new file mode 100644
index 000000000000..500580055e8c
--- /dev/null
+++ b/docs/articles/new-expensify/connections/dualentry/_meta.yml
@@ -0,0 +1 @@
+title: DualEntry
diff --git a/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Reports.md b/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Reports.md
index 26b11e6bcace..22f8864830a6 100644
--- a/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Reports.md
+++ b/docs/articles/new-expensify/reports-and-expenses/How-to-Export-Reports.md
@@ -60,6 +60,13 @@ Expensify offers pre-built export templates, or you can build your own custom ex
**Note** Currently, it's not possible to build custom export templates on New Expensify, they can only be created on Expensify Classic. However, once built they will be available on New Expensify when exporting reports. [Learn how to build a custom export template in Expensify Classic](/articles/expensify-classic/spending-insights/Export-Expenses-And-Reports#create-a-custom-export-template).
+When you export, the file either downloads immediately or is prepared in the background:
+
+- **Immediate download** – **Basic export** and **Export current view**, when you export selected reports or use **Select all on this page**.
+- **Prepared in the background** – When you use **Select all** to export all matching reports, or select **All Data - expense level**, **All Data - report level**, or a **Custom template**.
+
+While an export is being prepared, either wait for it to download automatically or select **Send me the file when it's ready** to close the export window and receive the file later. When it's ready, Expensify delivers it through Concierge and by email. If the export can't be generated, an error appears in the export window, or is delivered through Concierge if **Send me the file when it's ready** was selected.
+
## How to download a single report as a PDF
1. In the navigation tabs (on the left on web, on the bottom on mobile), go to **Spend > Reports**.
@@ -111,7 +118,7 @@ You can download the receipts on several reports at once in a single ZIP file.
## Where do I find the exported CSV file?
-For the Basic Export template, the file downloads directly to your device. For all other templates, Concierge sends the file to you in a direct message. Open your Concierge chat in the **Inbox** to find it.
+**Basic export** and **Export current view** download directly to your device. Other templates — and any export started with **Select all** — are prepared in the background; the file then downloads automatically, or is delivered through Concierge and by email if you selected **Send me the file when it's ready**.
## What happens if some reports fail to download as PDFs?
diff --git a/ios/NewExpensify/Info.plist b/ios/NewExpensify/Info.plist
index 9cdd4527ec98..476472cf753b 100644
--- a/ios/NewExpensify/Info.plist
+++ b/ios/NewExpensify/Info.plist
@@ -23,7 +23,7 @@
CFBundlePackageType
APPL
CFBundleShortVersionString
- 9.4.74
+ 9.4.75
CFBundleSignature
????
CFBundleURLTypes
@@ -44,7 +44,7 @@
CFBundleVersion
- 9.4.74.0
+ 9.4.75.0
FullStory
OrgId
diff --git a/ios/NotificationServiceExtension/Info.plist b/ios/NotificationServiceExtension/Info.plist
index 6c1f0c80bf39..5451d1bad1d3 100644
--- a/ios/NotificationServiceExtension/Info.plist
+++ b/ios/NotificationServiceExtension/Info.plist
@@ -11,9 +11,9 @@
CFBundleName
$(PRODUCT_NAME)
CFBundleShortVersionString
- 9.4.74
+ 9.4.75
CFBundleVersion
- 9.4.74.0
+ 9.4.75.0
NSExtension
NSExtensionPointIdentifier
diff --git a/ios/ShareViewController/Info.plist b/ios/ShareViewController/Info.plist
index 92588233b07c..f30055e31729 100644
--- a/ios/ShareViewController/Info.plist
+++ b/ios/ShareViewController/Info.plist
@@ -11,9 +11,9 @@
CFBundleName
$(PRODUCT_NAME)
CFBundleShortVersionString
- 9.4.74
+ 9.4.75
CFBundleVersion
- 9.4.74.0
+ 9.4.75.0
NSExtension
NSExtensionAttributes
diff --git a/jest.config.js b/jest.config.js
index 14b6e257de98..c929d2249591 100644
--- a/jest.config.js
+++ b/jest.config.js
@@ -25,7 +25,7 @@ module.exports = {
'^.+\\.svg?$': 'jest-transformer-svg',
},
transformIgnorePatterns: [
- '/node_modules/(?!.*(react-native|expo|react-navigation|uuid|@shopify\/flash-list).*/)',
+ '/node_modules/(?!.*(react-native|expo|react-navigation|uuid).*/)',
// Prevent Babel from transforming worklets in this file so they are treated as normal functions, otherwise FormatSelectionUtilsTest won't run.
'/node_modules/@expensify/react-native-live-markdown/lib/commonjs/parseExpensiMark.js',
],
diff --git a/jest/setup.ts b/jest/setup.ts
index 98bd75c5a569..cfab21045fdb 100644
--- a/jest/setup.ts
+++ b/jest/setup.ts
@@ -1,6 +1,5 @@
import type {RenderInfo} from '@components/FlatList/RenderTaskQueue';
-import '@shopify/flash-list/jestSetup';
import type * as LegendListModule from '@legendapp/list/react-native';
import type {ReactNode} from 'react';
import type React from 'react';
diff --git a/jest/setupAfterEnv.ts b/jest/setupAfterEnv.ts
index 49701c9c77bb..2bf9decad871 100644
--- a/jest/setupAfterEnv.ts
+++ b/jest/setupAfterEnv.ts
@@ -37,35 +37,6 @@ if (Keyboard && typeof Keyboard.addListener === 'function') {
}) as typeof Keyboard.addListener;
}
-// This mock must live in setupAfterEnv (not setupFiles) because @shopify/flash-list/jestSetup,
-// imported in setup.ts, registers its own measureLayout mock. Placing ours here ensures it
-// runs after FlashList's setup and takes precedence.
-jest.mock(
- '@shopify/flash-list/dist/recyclerview/utils/measureLayout',
- () =>
- ({
- ...jest.requireActual('@shopify/flash-list/dist/recyclerview/utils/measureLayout'),
- measureParentSize: jest.fn().mockImplementation(() => ({
- x: 0,
- y: 0,
- width: 300,
- height: 400,
- })),
- measureFirstChildLayout: jest.fn().mockImplementation(() => ({
- x: 0,
- y: 0,
- width: 300,
- height: 400,
- })),
- measureItemLayout: jest.fn().mockImplementation(() => ({
- x: 0,
- y: 0,
- width: 300,
- height: 75,
- })),
- }) as Record,
-);
-
// Auto-initialize Onyx for tests.
// Tests that already call Onyx.init() in their own beforeAll will safely re-configure Onyx —
// the second init() just re-runs initStoreValues and re-resolves the already-resolved deferred task.
diff --git a/package-lock.json b/package-lock.json
index 9f281864e26b..4b4f2f33d033 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
{
"name": "new.expensify",
- "version": "9.4.74-0",
+ "version": "9.4.75-0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "new.expensify",
- "version": "9.4.74-0",
+ "version": "9.4.75-0",
"hasInstallScript": true,
"license": "MIT",
"workspaces": [
@@ -56,7 +56,6 @@
"@sbaiahmed1/react-native-biometrics": "0.15.0",
"@sentry/core": "10.47.0",
"@sentry/react-native": "8.7.0",
- "@shopify/flash-list": "2.3.0",
"@shopify/react-native-skia": "^2.4.18",
"@ua/react-native-airship": "26.5.0",
"array.prototype.tosorted": "^1.1.4",
@@ -112,7 +111,6 @@
"react-native-collapsible": "^1.6.2",
"react-native-config": "1.5.3",
"react-native-device-info": "10.3.1",
- "react-native-draggable-flatlist": "^4.0.3",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "2.32.0",
"react-native-google-places-autocomplete": "2.6.4",
@@ -125,7 +123,7 @@
"react-native-nitro-fetch": "1.5.4",
"react-native-nitro-modules": "0.36.3",
"react-native-nitro-sqlite": "9.6.0",
- "react-native-onyx": "3.0.110",
+ "react-native-onyx": "3.0.111",
"react-native-pager-view": "8.0.0",
"react-native-pdf": "7.0.2",
"react-native-permissions": "^5.4.0",
@@ -16964,17 +16962,6 @@
"webpack": ">=5.0.0"
}
},
- "node_modules/@shopify/flash-list": {
- "version": "2.3.0",
- "resolved": "https://registry.npmjs.org/@shopify/flash-list/-/flash-list-2.3.0.tgz",
- "integrity": "sha512-DR7VuN8KJHTYj9zv1/IhpqrMBMQyeeW/DCWCbVQAAkWhHrc6ylIbXOY+qK93CuHABV+dNHXK/3V6p4wCSW/+wA==",
- "license": "MIT",
- "peerDependencies": {
- "@babel/runtime": "*",
- "react": "*",
- "react-native": "*"
- }
- },
"node_modules/@shopify/react-native-skia": {
"version": "2.4.18",
"resolved": "https://registry.npmjs.org/@shopify/react-native-skia/-/react-native-skia-2.4.18.tgz",
@@ -35420,18 +35407,6 @@
"react-native": "*"
}
},
- "node_modules/react-native-draggable-flatlist": {
- "version": "4.0.3",
- "license": "MIT",
- "dependencies": {
- "@babel/preset-typescript": "^7.17.12"
- },
- "peerDependencies": {
- "react-native": ">=0.64.0",
- "react-native-gesture-handler": ">=2.0.0",
- "react-native-reanimated": ">=2.8.0"
- }
- },
"node_modules/react-native-fs": {
"version": "2.20.0",
"license": "MIT",
@@ -35843,9 +35818,9 @@
}
},
"node_modules/react-native-onyx": {
- "version": "3.0.110",
- "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-3.0.110.tgz",
- "integrity": "sha512-Uo6z6ILQ5+pa+VtQVaSj8JU41JZ4v1o+H9uaBjaS6+sbzQWuxe5ds3Db+/pOVUk5trblL0Evfxg8B1f+LROFaA==",
+ "version": "3.0.111",
+ "resolved": "https://registry.npmjs.org/react-native-onyx/-/react-native-onyx-3.0.111.tgz",
+ "integrity": "sha512-Y8vGElYidypujOIhdCU6HE69ZH1ZEvyWShZvXiKBMVjWhUmQ/dDCtbk6ypc+q1LS3lJ5EIjNPNmQD668hUKbsQ==",
"license": "MIT",
"dependencies": {
"ascii-table": "0.0.9",
diff --git a/package.json b/package.json
index 526863340136..af3a5aa3e509 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "new.expensify",
- "version": "9.4.74-0",
+ "version": "9.4.75-0",
"author": "Expensify, Inc.",
"homepage": "https://new.expensify.com",
"description": "New Expensify is the next generation of Expensify: a reimagination of payments based atop a foundation of chat.",
@@ -132,7 +132,6 @@
"@sbaiahmed1/react-native-biometrics": "0.15.0",
"@sentry/core": "10.47.0",
"@sentry/react-native": "8.7.0",
- "@shopify/flash-list": "2.3.0",
"@shopify/react-native-skia": "^2.4.18",
"@ua/react-native-airship": "26.5.0",
"array.prototype.tosorted": "^1.1.4",
@@ -188,7 +187,6 @@
"react-native-collapsible": "^1.6.2",
"react-native-config": "1.5.3",
"react-native-device-info": "10.3.1",
- "react-native-draggable-flatlist": "^4.0.3",
"react-native-fs": "^2.20.0",
"react-native-gesture-handler": "2.32.0",
"react-native-google-places-autocomplete": "2.6.4",
@@ -201,7 +199,7 @@
"react-native-nitro-fetch": "1.5.4",
"react-native-nitro-modules": "0.36.3",
"react-native-nitro-sqlite": "9.6.0",
- "react-native-onyx": "3.0.110",
+ "react-native-onyx": "3.0.111",
"react-native-pager-view": "8.0.0",
"react-native-pdf": "7.0.2",
"react-native-permissions": "^5.4.0",
diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch
deleted file mode 100644
index e9d9e3dfd981..000000000000
--- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch
+++ /dev/null
@@ -1,51 +0,0 @@
-diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js b/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js
-index fb40ded..12375d9 100644
---- a/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js
-+++ b/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js
-@@ -92,6 +92,17 @@ export class RVLinearLayoutManagerImpl extends RVLayoutManager {
- */
- normalizeLayoutHeights(layoutInfo) {
- var _a, _b;
-+ // If the tallest item was removed from the list (e.g. item deletion),
-+ // reset tracking and clear minHeight so items get re-measured naturally.
-+ if (this.tallestItem && !this.layouts.includes(this.tallestItem)) {
-+ for (const layout of this.layouts) {
-+ layout.minHeight = 0;
-+ }
-+ this.tallestItem = undefined;
-+ this.tallestItemHeight = 0;
-+ this.requiresRepaint = true;
-+ return;
-+ }
- let newTallestItem;
- for (const info of layoutInfo) {
- const { index } = info;
-@@ -115,8 +126,26 @@ export class RVLinearLayoutManagerImpl extends RVLayoutManager {
- layout.minHeight = targetMinHeight;
- }
- newTallestItem.minHeight = 0;
-- this.tallestItem = newTallestItem;
-- this.tallestItemHeight = newTallestItem.height;
-+ // When items shrink (targetMinHeight = 0), reset tracking so the
-+ // next cycle re-detects the tallest item after repaint and properly
-+ // re-applies minHeight for all layouts.
-+ if (targetMinHeight === 0) {
-+ this.tallestItem = undefined;
-+ this.tallestItemHeight = 0;
-+ } else {
-+ this.tallestItem = newTallestItem;
-+ this.tallestItemHeight = newTallestItem.height;
-+ }
-+ return;
-+ }
-+ // Normalize newly added items that haven't been assigned minHeight yet.
-+ if (this.tallestItem) {
-+ for (const layout of this.layouts) {
-+ if (layout !== this.tallestItem && layout.minHeight !== this.tallestItemHeight) {
-+ layout.minHeight = this.tallestItemHeight;
-+ layout.height = this.tallestItemHeight;
-+ }
-+ }
- }
- }
- /**
diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch
deleted file mode 100644
index eae8317799bf..000000000000
--- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch
+++ /dev/null
@@ -1,49 +0,0 @@
-diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
-index 8b75322..dd2d3bc 100644
---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
-+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
-@@ -74,6 +74,10 @@ const RecyclerViewComponent = (props, ref) => {
- if (internalViewRef.current && firstChildViewRef.current) {
- // Measure the outer container size and inner container layout
- const outerViewSize = measureParentSize(internalViewRef.current);
-+ if (outerViewSize.width === 0 && outerViewSize.height === 0) {
-+ containerViewSizeRef.current = outerViewSize;
-+ return;
-+ }
- const firstChildViewLayout = measureFirstChildLayout(firstChildViewRef.current, internalViewRef.current);
- containerViewSizeRef.current = outerViewSize;
- // firstChildViewLayout is already relative to the outer container,
-@@ -103,6 +107,10 @@ const RecyclerViewComponent = (props, ref) => {
- if (pendingChildIds.size > 0) {
- return;
- }
-+ if (((_a = containerViewSizeRef.current) === null || _a === void 0 ? void 0 : _a.width) === 0 &&
-+ ((_b = containerViewSizeRef.current) === null || _b === void 0 ? void 0 : _b.height) === 0) {
-+ return;
-+ }
- const layoutInfo = Array.from(refHolder, ([index, viewHolderRef]) => {
- const layout = measureItemLayout(viewHolderRef.current, recyclerViewManager.tryGetLayout(index));
- // comapre height with stored layout
-diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js
-index 70f856a..9908674 100644
---- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js
-+++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js
-@@ -165,7 +165,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe
- const { horizontal } = recyclerViewManager.props;
- if (scrollViewRef.current) {
- // Adjust offset for RTL layouts in horizontal mode
-- if (I18nManager.isRTL && horizontal) {
-+ if (I18nManager.isRTL && horizontal && recyclerViewManager.hasLayout()) {
- // eslint-disable-next-line no-param-reassign
- offset =
- adjustOffsetForRTL(offset, recyclerViewManager.getChildContainerDimensions().width, recyclerViewManager.getWindowSize().width) +
-@@ -235,6 +235,9 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe
- * Returns a Promise that resolves when the scroll is complete.
- */
- scrollToIndex: ({ index, animated, viewPosition, viewOffset, }) => {
-+ if (!recyclerViewManager.hasLayout()) {
-+ return Promise.resolve();
-+ }
- return new Promise((resolve) => {
- const { horizontal } = recyclerViewManager.props;
- if (scrollViewRef.current &&
diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch
deleted file mode 100644
index edb436a356b4..000000000000
--- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch
+++ /dev/null
@@ -1,191 +0,0 @@
-diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
-index dd2d3bc..d7a3d84 100644
---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
-+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
-@@ -2,8 +2,8 @@
- * RecyclerView is a high-performance list component that efficiently renders and recycles list items.
- * It's designed to handle large lists with optimal memory usage and smooth scrolling.
- */
--import React, { useCallback, useLayoutEffect, useMemo, useRef, forwardRef, useState, useId, } from "react";
--import { Animated, I18nManager, } from "react-native";
-+import React, { useCallback, useEffect, useLayoutEffect, useMemo, useRef, forwardRef, useState, useId, } from "react";
-+import { Animated, I18nManager, Platform, } from "react-native";
- import { ErrorMessages } from "../errors/ErrorMessages";
- import { WarningMessages } from "../errors/WarningMessages";
- import { areDimensionsNotEqual, measureFirstChildLayout, measureItemLayout, measureParentSize, } from "./utils/measureLayout";
-@@ -66,6 +66,66 @@ const RecyclerViewComponent = (props, ref) => {
- // Hook to detect when scrolling reaches list bounds
- const { checkBounds } = useBoundDetection(recyclerViewManager, scrollViewRef);
- const isHorizontalRTL = I18nManager.isRTL && horizontal;
-+ // Web-only: Fix inverted scroll direction.
-+ useEffect(() => {
-+ if (!inverted || Platform.OS !== "web") {
-+ return;
-+ }
-+ const scrollRef = scrollViewRef.current;
-+ if (!scrollRef || typeof scrollRef.getScrollableNode !== "function") {
-+ return;
-+ }
-+ const node = scrollRef.getScrollableNode();
-+ if (!node) {
-+ return;
-+ }
-+ const wheelHandler = (ev) => {
-+ const target = ev.target;
-+ const deltaX = ev.deltaX || ev.wheelDeltaX || 0;
-+ const deltaY = ev.deltaY || ev.wheelDeltaY || 0;
-+ // Compute scroll limits from the DOM node for overscroll recoil prevention.
-+ const nodeScrollOffset = horizontal ? node.scrollLeft : node.scrollTop;
-+ const nodeScrollLength = horizontal ? node.scrollWidth : node.scrollHeight;
-+ const nodeClientLength = horizontal ? node.clientWidth : node.clientHeight;
-+ const isOnScrollLimit = nodeScrollOffset <= 0 || Math.ceil(nodeScrollOffset) >= nodeScrollLength - nodeClientLength;
-+ const scrollOffset = horizontal ? target.scrollLeft : target.scrollTop;
-+ const scrollLength = horizontal ? target.scrollWidth : target.scrollHeight;
-+ const clientLength = horizontal ? target.clientWidth : target.clientHeight;
-+ const isEventTargetScrollable = scrollLength > clientLength;
-+ const delta = horizontal ? deltaX : deltaY;
-+ let leftoverDelta = delta;
-+ if (isEventTargetScrollable) {
-+ leftoverDelta = delta < 0
-+ ? Math.min(delta + scrollOffset, 0)
-+ : Math.max(delta - (scrollLength - clientLength - scrollOffset), 0);
-+ }
-+ const targetDelta = delta - leftoverDelta;
-+ if (horizontal) {
-+ if (Math.abs(deltaX) > Math.abs(deltaY)) {
-+ target.scrollLeft += targetDelta;
-+ node.scrollLeft = node.scrollLeft - leftoverDelta;
-+ ev.preventDefault();
-+ ev.stopPropagation();
-+ }
-+ }
-+ else {
-+ // Prevent overscroll recoil/rubber band at scroll boundaries.
-+ if (isOnScrollLimit && Math.abs(deltaY) > 0) {
-+ ev.preventDefault();
-+ }
-+ if (Math.abs(deltaY) > Math.abs(deltaX)) {
-+ target.scrollTop += targetDelta;
-+ node.scrollTop = node.scrollTop - leftoverDelta;
-+ ev.preventDefault();
-+ ev.stopPropagation();
-+ }
-+ }
-+ };
-+ node.addEventListener("wheel", wheelHandler, { passive: false });
-+ return () => {
-+ node.removeEventListener("wheel", wheelHandler);
-+ };
-+ }, [inverted, horizontal]);
- /**
- * Initialize the RecyclerView by measuring and setting up the window size
- * This effect runs when the component mounts or when layout changes
-diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx
-index 34722d4..ea801d2 100644
---- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx
-+++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx
-@@ -5,6 +5,7 @@
- import React, {
- RefObject,
- useCallback,
-+ useEffect,
- useLayoutEffect,
- useMemo,
- useRef,
-@@ -17,6 +18,7 @@ import {
- I18nManager,
- NativeScrollEvent,
- NativeSyntheticEvent,
-+ Platform,
- } from "react-native";
-
- import { FlashListRef } from "../FlashListRef";
-@@ -158,6 +160,88 @@ const RecyclerViewComponent = (
-
- const isHorizontalRTL = I18nManager.isRTL && horizontal;
-
-+ /**
-+ * Web-only: Fix inverted scroll direction.
-+ * When a list is visually inverted via scaleY/scaleX: -1, the browser's native
-+ * wheel scroll goes in the wrong visual direction. This effect attaches a wheel
-+ * event listener that negates the delta to correct the scroll direction.
-+ * Mirrors the fix in react-native-web's VirtualizedList.
-+ */
-+ useEffect(() => {
-+ if (!inverted || Platform.OS !== "web") {
-+ return;
-+ }
-+ const scrollRef = scrollViewRef.current;
-+ if (!scrollRef || typeof (scrollRef as any).getScrollableNode !== "function") {
-+ return;
-+ }
-+ const node = (scrollRef as any).getScrollableNode() as HTMLElement;
-+ if (!node) {
-+ return;
-+ }
-+
-+ const wheelHandler = (ev: WheelEvent) => {
-+ const target = ev.target as HTMLElement;
-+ const deltaX = ev.deltaX || (ev as any).wheelDeltaX || 0;
-+ const deltaY = ev.deltaY || (ev as any).wheelDeltaY || 0;
-+
-+ // Compute scroll limits from the DOM node for overscroll recoil prevention.
-+ const nodeScrollOffset = horizontal ? node.scrollLeft : node.scrollTop;
-+ const nodeScrollLength = horizontal ? node.scrollWidth : node.scrollHeight;
-+ const nodeClientLength = horizontal ? node.clientWidth : node.clientHeight;
-+ const isOnScrollLimit =
-+ nodeScrollOffset <= 0 ||
-+ Math.ceil(nodeScrollOffset) >= nodeScrollLength - nodeClientLength;
-+
-+ const scrollOffset = horizontal ? target.scrollLeft : target.scrollTop;
-+ const scrollLength = horizontal ? target.scrollWidth : target.scrollHeight;
-+ const clientLength = horizontal ? target.clientWidth : target.clientHeight;
-+ const isEventTargetScrollable = scrollLength > clientLength;
-+ const delta = horizontal ? deltaX : deltaY;
-+
-+ // Calculate how much delta the event target can consume vs leftover for parent
-+ let leftoverDelta = delta;
-+ if (isEventTargetScrollable) {
-+ leftoverDelta =
-+ delta < 0
-+ ? Math.min(delta + scrollOffset, 0)
-+ : Math.max(
-+ delta - (scrollLength - clientLength - scrollOffset),
-+ 0
-+ );
-+ }
-+ const targetDelta = delta - leftoverDelta;
-+
-+ // Only adjust scroll and consume the event when the dominant axis
-+ // matches the list orientation. stopPropagation prevents parent
-+ // inverted lists from also handling this event.
-+ if (horizontal) {
-+ if (Math.abs(deltaX) > Math.abs(deltaY)) {
-+ target.scrollLeft += targetDelta;
-+ node.scrollLeft = node.scrollLeft - leftoverDelta;
-+ ev.preventDefault();
-+ ev.stopPropagation();
-+ }
-+ } else {
-+ // Prevent overscroll recoil/rubber band at scroll boundaries.
-+ if (isOnScrollLimit && Math.abs(deltaY) > 0) {
-+ ev.preventDefault();
-+ }
-+ if (Math.abs(deltaY) > Math.abs(deltaX)) {
-+ target.scrollTop += targetDelta;
-+ node.scrollTop = node.scrollTop - leftoverDelta;
-+ ev.preventDefault();
-+ ev.stopPropagation();
-+ }
-+ }
-+ };
-+
-+ node.addEventListener("wheel", wheelHandler, { passive: false });
-+ return () => {
-+ node.removeEventListener("wheel", wheelHandler);
-+ };
-+ }, [inverted, horizontal]);
-+
- /**
- * Initialize the RecyclerView by measuring and setting up the window size
- * This effect runs when the component mounts or when layout changes
diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch
deleted file mode 100644
index 98bac124be64..000000000000
--- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch
+++ /dev/null
@@ -1,38 +0,0 @@
-diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
-index d7a3d84..ffcdad8 100644
---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
-+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
-@@ -142,9 +142,11 @@ const RecyclerViewComponent = (props, ref) => {
- containerViewSizeRef.current = outerViewSize;
- // firstChildViewLayout is already relative to the outer container,
- // so its x/y directly gives the first item offset.
-- const firstItemOffset = horizontal
-- ? firstChildViewLayout.x
-- : firstChildViewLayout.y;
-+ const firstItemOffset = inverted
-+ ? 0
-+ : horizontal
-+ ? firstChildViewLayout.x
-+ : firstChildViewLayout.y;
- // Update the RecyclerView manager with window dimensions
- recyclerViewManager.updateLayoutParams({
- width: horizontal ? outerViewSize.width : firstChildViewLayout.width,
-diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx
-index ea801d2..8a7deff 100644
---- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx
-+++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx
-@@ -259,9 +259,11 @@ const RecyclerViewComponent = (
-
- // firstChildViewLayout is already relative to the outer container,
- // so its x/y directly gives the first item offset.
-- const firstItemOffset = horizontal
-- ? firstChildViewLayout.x
-- : firstChildViewLayout.y;
-+ const firstItemOffset = inverted
-+ ? 0
-+ : horizontal
-+ ? firstChildViewLayout.x
-+ : firstChildViewLayout.y;
-
- // Update the RecyclerView manager with window dimensions
- recyclerViewManager.updateLayoutParams(
diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch
deleted file mode 100644
index 6b96845c5875..000000000000
--- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch
+++ /dev/null
@@ -1,68 +0,0 @@
-diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
-index ffcdad8..ee42f63 100644
---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
-+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
-@@ -166,9 +166,6 @@ const RecyclerViewComponent = (props, ref) => {
- // eslint-disable-next-line react-hooks/exhaustive-deps
- useLayoutEffect(() => {
- var _a, _b;
-- if (pendingChildIds.size > 0) {
-- return;
-- }
- if (((_a = containerViewSizeRef.current) === null || _a === void 0 ? void 0 : _a.width) === 0 &&
- ((_b = containerViewSizeRef.current) === null || _b === void 0 ? void 0 : _b.height) === 0) {
- return;
-@@ -196,8 +193,17 @@ const RecyclerViewComponent = (props, ref) => {
- }
- if (recyclerViewManager.modifyChildrenLayout(layoutInfo, (_a = data === null || data === void 0 ? void 0 : data.length) !== null && _a !== void 0 ? _a : 0) &&
- !hasExceededMaxRendersWithoutCommit) {
-- // Trigger re-render if layout modifications were made
-- setRenderId((prev) => prev + 1);
-+ if (pendingChildIds.size > 0) {
-+ // When child FlashLists are still loading, avoid triggering a full
-+ // RecyclerView re-render (setRenderId) to prevent cascading setState
-+ // calls that could cause "Maximum update depth exceeded" errors.
-+ // Instead, just commit the layout to update item positions in
-+ // ViewHolderCollection without re-measuring.
-+ (_b = viewHolderCollectionRef.current) === null || _b === void 0 ? void 0 : _b.commitLayout();
-+ } else {
-+ // Trigger re-render if layout modifications were made
-+ setRenderId((prev) => prev + 1);
-+ }
- }
- else {
- (_b = viewHolderCollectionRef.current) === null || _b === void 0 ? void 0 : _b.commitLayout();
-diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx
-index 8a7deff..b2bd67a 100644
---- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx
-+++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx
-@@ -287,9 +287,6 @@ const RecyclerViewComponent = (
- */
- // eslint-disable-next-line react-hooks/exhaustive-deps
- useLayoutEffect(() => {
-- if (pendingChildIds.size > 0) {
-- return;
-- }
- const layoutInfo = Array.from(refHolder, ([index, viewHolderRef]) => {
- const layout = measureItemLayout(
- viewHolderRef.current!,
-@@ -323,8 +320,17 @@ const RecyclerViewComponent = (
- recyclerViewManager.modifyChildrenLayout(layoutInfo, data?.length ?? 0) &&
- !hasExceededMaxRendersWithoutCommit
- ) {
-- // Trigger re-render if layout modifications were made
-- setRenderId((prev) => prev + 1);
-+ if (pendingChildIds.size > 0) {
-+ // When child FlashLists are still loading, avoid triggering a full
-+ // RecyclerView re-render (setRenderId) to prevent cascading setState
-+ // calls that could cause "Maximum update depth exceeded" errors.
-+ // Instead, just commit the layout to update item positions in
-+ // ViewHolderCollection without re-measuring.
-+ viewHolderCollectionRef.current?.commitLayout();
-+ } else {
-+ // Trigger re-render if layout modifications were made
-+ setRenderId((prev) => prev + 1);
-+ }
- } else {
- viewHolderCollectionRef.current?.commitLayout();
- applyOffsetCorrection();
diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch
deleted file mode 100644
index 48a9b893bbfd..000000000000
--- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch
+++ /dev/null
@@ -1,114 +0,0 @@
-diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js
-index 70f856a..52546f7 100644
---- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js
-+++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js
-@@ -1,5 +1,5 @@
- import { useCallback, useImperativeHandle, useMemo, useRef, useState, } from "react";
--import { I18nManager } from "react-native";
-+import { I18nManager, Platform } from "react-native";
- import { adjustOffsetForRTL } from "../utils/adjustOffsetForRTL";
- import { PlatformConfig } from "../../native/config/PlatformHelper";
- import { WarningMessages } from "../../errors/WarningMessages";
-@@ -25,6 +25,8 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe
- const isUnmounted = useUnmountFlag();
- const [_, setRenderId] = useState(0);
- const pauseOffsetCorrection = useRef(false);
-+ const pendingAndroidInvertedRafId = useRef(null);
-+ const skipNextAndroidInvertedCorrection = useRef(false);
- const lastDataLengthRef = useRef(recyclerViewManager.getDataLength());
- const { setTimeout } = useUnmountAwareTimeout();
- // Track the first visible item for maintaining scroll position
-@@ -79,7 +81,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe
- */
- const applyOffsetCorrection = useCallback(() => {
- var _a, _b, _c;
-- const { horizontal, data } = recyclerViewManager.props;
-+ const { horizontal, data, inverted } = recyclerViewManager.props;
- // Execute all pending callbacks from previous scroll offset updates
- // This ensures any scroll operations that were waiting for render are completed
- const callbacks = pendingScrollCallbacks.current;
-@@ -91,6 +93,11 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe
- currentDataLength > 0 &&
- recyclerViewManager.shouldMaintainVisibleContentPosition()) {
- const hasDataChanged = currentDataLength !== lastDataLengthRef.current;
-+ // Read and reset the skip flag so it never persists across multiple correction cycles
-+ const shouldSkipAndroidInvertedCorrection = hasDataChanged && inverted && Platform.OS === 'android' && skipNextAndroidInvertedCorrection.current;
-+ if (shouldSkipAndroidInvertedCorrection) {
-+ skipNextAndroidInvertedCorrection.current = false;
-+ }
- // If we have a tracked first visible item, maintain its position
- if (firstVisibleItemKey.current) {
- const currentIndexOfFirstVisibleItem = (_a = recyclerViewManager
-@@ -115,10 +122,31 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe
- !pauseOffsetCorrection.current &&
- !recyclerViewManager.animationOptimizationsEnabled) {
- // console.log("diff", diff, firstVisibleItemKey.current);
-- if (PlatformConfig.supportsOffsetCorrection) {
-- // console.log("scrollBy", diff);
-+ const useAndroidInvertedFallback = hasDataChanged && inverted && Platform.OS === 'android';
-+ if (PlatformConfig.supportsOffsetCorrection && !useAndroidInvertedFallback) {
- (_b = scrollAnchorRef.current) === null || _b === void 0 ? void 0 : _b.scrollBy(diff);
- }
-+ else if (useAndroidInvertedFallback) {
-+ if (!shouldSkipAndroidInvertedCorrection) {
-+ const scrollToParams = horizontal
-+ ? {
-+ x: recyclerViewManager.getAbsoluteLastScrollOffset() + diff,
-+ animated: false,
-+ }
-+ : {
-+ y: recyclerViewManager.getAbsoluteLastScrollOffset() + diff,
-+ animated: false,
-+ };
-+ if (pendingAndroidInvertedRafId.current !== null) {
-+ cancelAnimationFrame(pendingAndroidInvertedRafId.current);
-+ }
-+ // rAF scrollTo to correct after native layout commits
-+ pendingAndroidInvertedRafId.current = requestAnimationFrame(() => {
-+ pendingAndroidInvertedRafId.current = null;
-+ (_c = scrollViewRef.current) === null || _c === void 0 ? void 0 : _c.scrollTo(scrollToParams);
-+ });
-+ }
-+ }
- else {
- const scrollToParams = horizontal
- ? {
-@@ -162,6 +190,13 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe
- * Handles RTL layouts and first item offset adjustments.
- */
- scrollToOffset: ({ offset, animated, skipFirstItemOffset = true, }) => {
-+ if (pendingAndroidInvertedRafId.current !== null) {
-+ cancelAnimationFrame(pendingAndroidInvertedRafId.current);
-+ pendingAndroidInvertedRafId.current = null;
-+ }
-+ if (recyclerViewManager.props.inverted && Platform.OS === 'android') {
-+ skipNextAndroidInvertedCorrection.current = true;
-+ }
- const { horizontal } = recyclerViewManager.props;
- if (scrollViewRef.current) {
- // Adjust offset for RTL layouts in horizontal mode
-@@ -205,6 +240,13 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe
- * Scrolls to the end of the list.
- */
- scrollToEnd: async ({ animated } = {}) => {
-+ if (pendingAndroidInvertedRafId.current !== null) {
-+ cancelAnimationFrame(pendingAndroidInvertedRafId.current);
-+ pendingAndroidInvertedRafId.current = null;
-+ }
-+ if (recyclerViewManager.props.inverted && Platform.OS === 'android') {
-+ skipNextAndroidInvertedCorrection.current = true;
-+ }
- const { data } = recyclerViewManager.props;
- if (data && data.length > 0) {
- const lastIndex = data.length - 1;
-@@ -238,6 +280,10 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe
- if (!recyclerViewManager.hasLayout()) {
- return Promise.resolve();
- }
-+ if (pendingAndroidInvertedRafId.current !== null) {
-+ cancelAnimationFrame(pendingAndroidInvertedRafId.current);
-+ pendingAndroidInvertedRafId.current = null;
-+ }
- return new Promise((resolve) => {
- const { horizontal } = recyclerViewManager.props;
- if (scrollViewRef.current &&
diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+007+fix-scroll-anchor-unmount-on-ios.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+007+fix-scroll-anchor-unmount-on-ios.patch
deleted file mode 100644
index 7f1700a548d9..000000000000
--- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+007+fix-scroll-anchor-unmount-on-ios.patch
+++ /dev/null
@@ -1,80 +0,0 @@
-diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
-index ee42f63..4e8d8c0 100644
---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
-+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
-@@ -380,16 +380,15 @@ const RecyclerViewComponent = (props, ref) => {
- }
- return onScrollHandler;
- }, [onScrollHandler, scrollY, stickyHeaders, stickyHeaderUseNativeDriver]);
-- const shouldMaintainVisibleContentPosition = recyclerViewManager.shouldMaintainVisibleContentPosition();
- const maintainVisibleContentPositionInternal = useMemo(() => {
-- if (shouldMaintainVisibleContentPosition) {
-+ if (maintainVisibleContentPosition != null && !maintainVisibleContentPosition.disabled) {
- return {
- ...maintainVisibleContentPosition,
- minIndexForVisible: 0,
- };
- }
- return undefined;
-- }, [maintainVisibleContentPosition, shouldMaintainVisibleContentPosition]);
-+ }, [maintainVisibleContentPosition]);
- const shouldRenderFromBottom = recyclerViewManager.getDataLength() > 0 &&
- ((_d = maintainVisibleContentPosition === null || maintainVisibleContentPosition === void 0 ? void 0 : maintainVisibleContentPosition.startRenderingFromBottom) !== null && _d !== void 0 ? _d : false);
- // Create view for measuring bounded size
-@@ -401,11 +399,11 @@ const RecyclerViewComponent = (props, ref) => {
- }, ref: firstChildViewRef }));
- }, [horizontal, stickyHeaderOffset]);
- const scrollAnchor = useMemo(() => {
-- if (shouldMaintainVisibleContentPosition) {
-+ if (maintainVisibleContentPosition != null) {
- return (React.createElement(ScrollAnchor, { horizontal: Boolean(horizontal), scrollAnchorRef: scrollAnchorRef }));
- }
- return null;
-- }, [horizontal, shouldMaintainVisibleContentPosition]);
-+ }, [horizontal, maintainVisibleContentPosition]);
- // console.log("render", recyclerViewManager.getRenderStack());
- // Render the main RecyclerView structure
- return (React.createElement(RecyclerViewContextProvider, { value: recyclerViewContext },
-diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx
-index b2bd67a..d4bf02d 100644
---- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx
-+++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx
-@@ -572,18 +572,15 @@ const RecyclerViewComponent = (
- return onScrollHandler;
- }, [onScrollHandler, scrollY, stickyHeaders, stickyHeaderUseNativeDriver]);
-
-- const shouldMaintainVisibleContentPosition =
-- recyclerViewManager.shouldMaintainVisibleContentPosition();
--
- const maintainVisibleContentPositionInternal = useMemo(() => {
-- if (shouldMaintainVisibleContentPosition) {
-+ if (maintainVisibleContentPosition != null && !maintainVisibleContentPosition.disabled) {
- return {
- ...maintainVisibleContentPosition,
- minIndexForVisible: 0,
- };
- }
- return undefined;
-- }, [maintainVisibleContentPosition, shouldMaintainVisibleContentPosition]);
-+ }, [maintainVisibleContentPosition]);
-
- const shouldRenderFromBottom =
- recyclerViewManager.getDataLength() > 0 &&
-@@ -604,7 +600,7 @@ const RecyclerViewComponent = (
- }, [horizontal, stickyHeaderOffset]);
-
- const scrollAnchor = useMemo(() => {
-- if (shouldMaintainVisibleContentPosition) {
-+ if (maintainVisibleContentPosition != null) {
- return (
- (
- );
- }
- return null;
-- }, [horizontal, shouldMaintainVisibleContentPosition]);
-+ }, [horizontal, maintainVisibleContentPosition]);
-
- // console.log("render", recyclerViewManager.getRenderStack());
-
diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+increase-timeout.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+increase-timeout.patch
deleted file mode 100644
index a76bba73cc2d..000000000000
--- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+008+increase-timeout.patch
+++ /dev/null
@@ -1,13 +0,0 @@
-diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js
-index 51b6f8c..d4ca252 100644
---- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js
-+++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js
-@@ -507,7 +507,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe
- setTimeout(() => {
- recyclerViewManager.isInitialScrollComplete = true;
- pauseOffsetCorrection.current = false;
-- }, 100);
-+ }, 500);
- pauseOffsetCorrection.current = true;
- const additionalOffset = (_c = initialScrollIndexParams === null || initialScrollIndexParams === void 0 ? void 0 : initialScrollIndexParams.viewOffset) !== null && _c !== void 0 ? _c : 0;
- const offset = horizontal
diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch
deleted file mode 100644
index 928c0d6c28a6..000000000000
--- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch
+++ /dev/null
@@ -1,29 +0,0 @@
-diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
-+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
-@@ -316,7 +316,10 @@ function RecyclerView(props) {
- */
- const validateItemSize = useCallback((index, size) => {
- var _a, _b, _c, _d;
-- const layout = recyclerViewManager.getLayout(index);
-+ const layout = recyclerViewManager.tryGetLayout(index);
-+ if (layout === undefined) {
-+ return;
-+ }
- const width = Math.max(Math.min(layout.width, (_a = layout.maxWidth) !== null && _a !== void 0 ? _a : Infinity), (_b = layout.minWidth) !== null && _b !== void 0 ? _b : 0);
- const height = Math.max(Math.min(layout.height, (_c = layout.maxHeight) !== null && _c !== void 0 ? _c : Infinity), (_d = layout.minHeight) !== null && _d !== void 0 ? _d : 0);
- if (areDimensionsNotEqual(width, size.width) ||
-diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx
---- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx
-+++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx
-@@ -465,6 +465,9 @@ function RecyclerView(props: RecyclerViewProps) {
- const validateItemSize = useCallback(
- (index: number, size: RVDimension) => {
-- const layout = recyclerViewManager.getLayout(index);
-+ const layout = recyclerViewManager.tryGetLayout(index);
-+ if (layout === undefined) {
-+ return;
-+ }
- const width = Math.max(
- Math.min(layout.width, layout.maxWidth ?? Infinity),
- layout.minWidth ?? 0
diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+010+fix-web-subpixel-rounding.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+010+fix-web-subpixel-rounding.patch
deleted file mode 100644
index c66e0497ccfd..000000000000
--- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+010+fix-web-subpixel-rounding.patch
+++ /dev/null
@@ -1,16 +0,0 @@
-diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/utils/measureLayout.web.js b/node_modules/@shopify/flash-list/dist/recyclerview/utils/measureLayout.web.js
-index 17d9812..fe112f1 100644
---- a/node_modules/@shopify/flash-list/dist/recyclerview/utils/measureLayout.web.js
-+++ b/node_modules/@shopify/flash-list/dist/recyclerview/utils/measureLayout.web.js
-@@ -28,7 +28,10 @@ export function areDimensionsEqual(value1, value2) {
- return Math.abs(value1 - value2) <= 1;
- }
- export function roundOffPixel(value) {
-- return value;
-+ const dpr = typeof window !== "undefined" && window.devicePixelRatio
-+ ? window.devicePixelRatio
-+ : 1;
-+ return Math.round(value * dpr) / dpr;
- }
- /**
- * Measures the size of the RecyclerView's outer container.
diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+011+sort-for-natural-DOM-order.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+011+sort-for-natural-DOM-order.patch
deleted file mode 100644
index 321a812fa2cc..000000000000
--- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+011+sort-for-natural-DOM-order.patch
+++ /dev/null
@@ -1,542 +0,0 @@
-diff --git a/node_modules/@shopify/flash-list/dist/FlashListRef.d.ts b/node_modules/@shopify/flash-list/dist/FlashListRef.d.ts
-index 08b83f3..05a64b1 100644
---- a/node_modules/@shopify/flash-list/dist/FlashListRef.d.ts
-+++ b/node_modules/@shopify/flash-list/dist/FlashListRef.d.ts
-@@ -167,6 +167,30 @@ export interface FlashListRef {
- * });
- */
- scrollToIndex: (params: ScrollToIndexParams) => Promise;
-+ /**
-+ * Announces an imminent programmatic scroll before `scrollToIndex` is
-+ * actually called, so DOM-mutating side-effects gated on
-+ * `isScrollingProgrammatically()` (notably the on-web sort applied by
-+ * `ViewHolderCollection`) defer until the upcoming smooth scroll
-+ * settles, rather than running synchronously and cancelling it.
-+ *
-+ * Useful when the focus assignment happens first and `scrollToIndex`
-+ * follows a few ticks later — as long as the call is guaranteed to
-+ * happen, queue it up front so the intervening `focusin` doesn't
-+ * trigger an immediate sort that the smooth scroll would then cancel.
-+ *
-+ * Cleared automatically when the next `scrollToIndex` is invoked
-+ * (handed off to the in-flight flag) and again when the resulting
-+ * scroll's momentum ends. Safe to call multiple times.
-+ *
-+ * @example
-+ * listRef.current?.announceProgrammaticScroll();
-+ * itemDomNode.focus();
-+ * setTimeout(() => {
-+ * listRef.current?.scrollToIndex({ index: nextIndex, animated: true });
-+ * }, 0);
-+ */
-+ announceProgrammaticScroll: () => void;
- /**
- * Scrolls to a specific item in the list.
- *
-diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
-index 4e53325..ef4daf2 100644
---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
-+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
-@@ -58,7 +58,7 @@ const RecyclerViewComponent = (props, ref) => {
- const refHolder = useMemo(() => new Map(), []);
- // Initialize core RecyclerView manager and content offset management
- const { recyclerViewManager, velocityTracker } = useRecyclerViewManager(props);
-- const { applyOffsetCorrection, computeFirstVisibleIndexForOffsetCorrection, applyInitialScrollIndex, handlerMethods, } = useRecyclerViewController(recyclerViewManager, ref, scrollViewRef, scrollAnchorRef);
-+ const { applyOffsetCorrection, computeFirstVisibleIndexForOffsetCorrection, applyInitialScrollIndex, handlerMethods, isScrollingProgrammatically, isScrolling, runAfterProgrammaticScroll, notifyProgrammaticScrollSettled, notifyScrollActive, notifyScrollSettled, getLastScrollTime, } = useRecyclerViewController(recyclerViewManager, ref, scrollViewRef, scrollAnchorRef);
- // Initialize view holder collection ref
- const viewHolderCollectionRef = useRef(null);
- // Hook to handle list loading
-@@ -238,12 +238,19 @@ const RecyclerViewComponent = (props, ref) => {
- return;
- }
- if (isMomentumEnd) {
-+ notifyScrollSettled();
-+ // Drain BEFORE the early return below so the drain still
-+ // fires while offset projection is still disabled.
-+ notifyProgrammaticScrollSettled();
- computeFirstVisibleIndexForOffsetCorrection();
- if (!recyclerViewManager.isOffsetProjectionEnabled) {
- return;
- }
- recyclerViewManager.resetVelocityCompute();
- }
-+ else {
-+ notifyScrollActive();
-+ }
- // Update scroll position and trigger re-render if needed
- if (recyclerViewManager.updateScrollOffset(scrollOffset, velocity)) {
- setRenderId((prev) => prev + 1);
-@@ -266,6 +273,9 @@ const RecyclerViewComponent = (props, ref) => {
- computeFirstVisibleIndexForOffsetCorrection,
- horizontal,
- isHorizontalRTL,
-+ notifyProgrammaticScrollSettled,
-+ notifyScrollActive,
-+ notifyScrollSettled,
- recyclerViewManager,
- velocityTracker,
- ]);
-@@ -461,7 +471,7 @@ const RecyclerViewComponent = (props, ref) => {
- recyclerViewManager.animationOptimizationsEnabled = false;
- }, CellRendererComponent: CellRendererComponent, ItemSeparatorComponent: ItemSeparatorComponent, isInLastRow: (index) => recyclerViewManager.isInLastRow(index), getChildContainerLayout: () => recyclerViewManager.hasLayout()
- ? recyclerViewManager.getChildContainerDimensions()
-- : undefined, currentStickyIndex: currentStickyIndex, hideStickyHeaderRelatedCell: stickyHeaderHideRelatedCell, inverted: inverted }),
-+ : undefined, currentStickyIndex: currentStickyIndex, hideStickyHeaderRelatedCell: stickyHeaderHideRelatedCell, inverted: inverted, isScrollingProgrammatically: isScrollingProgrammatically, isScrolling: isScrolling, runAfterProgrammaticScroll: runAfterProgrammaticScroll, getLastScrollTime: getLastScrollTime }),
- renderEmpty,
- renderFooter),
- stickyHeaderIndices && stickyHeaderIndices.length > 0
-diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolder.js b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolder.js
-index 0df2879..f639313 100644
---- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolder.js
-+++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolder.js
-@@ -3,9 +3,11 @@
- * It handles the rendering of list items, separators, and manages layout updates for each item.
- * The component is memoized to prevent unnecessary re-renders and includes layout comparison logic.
- */
-+import { Platform } from "react-native";
- import React, { useCallback, useLayoutEffect, useMemo, useRef, } from "react";
- import { CompatView } from "./components/CompatView";
- import { getInvertedTransformStyle } from "./utils/getInvertedTransformStyle";
-+const INVISIBLE_MARKER_STYLE = { display: "none" };
- /**
- * Internal ViewHolder component that handles the actual rendering of list items
- * @template TItem - The type of item being rendered in the list
-@@ -57,6 +59,7 @@ const ViewHolderInternal = (props) => {
- const CompatContainer = (CellRendererComponent !== null && CellRendererComponent !== void 0 ? CellRendererComponent : CompatView);
- return (React.createElement(CompatContainer, { ref: viewRef, onLayout: onLayout, style: style, index: index },
- children,
-+ Platform.OS === "web" && (React.createElement("div", { "data-flashlist-index": index, "aria-hidden": true, style: INVISIBLE_MARKER_STYLE })),
- separator));
- };
- /**
-diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts
-index c37c4f3..fd2ff94 100644
---- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts
-+++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts
-@@ -54,6 +54,14 @@ export interface ViewHolderCollectionProps {
- isInLastRow: (index: number) => boolean;
- /** Whether the list is inverted */
- inverted: FlashListProps["inverted"];
-+ /** True while a programmatic scroll is queued or in flight. */
-+ isScrollingProgrammatically: () => boolean;
-+ /** True while any scroll is in flight. */
-+ isScrolling: () => boolean;
-+ /** Register a callback to run when the current programmatic-scroll animation settles. */
-+ runAfterProgrammaticScroll: (cb: () => void) => void;
-+ /** Returns the timestamp (`Date.now()`) of the most recent scroll event, or 0 if none. */
-+ getLastScrollTime: () => number;
- }
- /**
- * Ref interface for ViewHolderCollection that exposes methods to control layout updates
-diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js
-index 8e3db51..e66d406 100644
---- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js
-+++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js
-@@ -3,17 +3,81 @@
- * It handles the rendering of a collection of list items, manages layout updates,
- * and coordinates with the RecyclerView context for layout changes.
- */
--import React, { useEffect, useImperativeHandle, useLayoutEffect } from "react";
-+import React, { useCallback, useEffect, useImperativeHandle, useLayoutEffect, useReducer, useRef, } from "react";
-+import { Platform } from "react-native";
- import { ViewHolder } from "./ViewHolder";
- import { CompatView } from "./components/CompatView";
- import { useRecyclerViewContext } from "./RecyclerViewContextProvider";
-+const SORT_DELAY_MS = 1000;
-+// Max gap from last `focusin` to last `scroll` event for the scroll to
-+// count as a focus-induced auto-scroll-into-view (vs a user-driven scroll).
-+const FOCUS_INDUCED_SCROLL_WINDOW_MS = 30;
-+/**
-+ * Single-slot setTimeout with a fire-time gate. Calling `schedule` again
-+ * replaces any pending fire. When the timer expires, if `shouldDefer()`
-+ * returns true the timer reschedules itself instead of invoking
-+ * `callback`. Auto-cancels on unmount.
-+ *
-+ * @returns A tuple of `[schedule, cancel]`. `schedule` arms (or re-arms)
-+ * the timer; `cancel` evicts whatever is in the slot.
-+ */
-+function useDeferredCallback(callback, delayMs, shouldDefer) {
-+ const timeoutRef = useRef(null);
-+ const cancel = useCallback(() => {
-+ if (timeoutRef.current !== null) {
-+ clearTimeout(timeoutRef.current);
-+ timeoutRef.current = null;
-+ }
-+ }, []);
-+ const schedule = useCallback(() => {
-+ cancel();
-+ timeoutRef.current = setTimeout(() => {
-+ if (shouldDefer()) {
-+ schedule();
-+ return;
-+ }
-+ timeoutRef.current = null;
-+ callback();
-+ }, delayMs);
-+ }, [callback, delayMs, shouldDefer, cancel]);
-+ useEffect(() => cancel, [cancel]);
-+ return [schedule, cancel];
-+}
-+/**
-+ * Walks up from `target` to find a `data-flashlist-index` marker among
-+ * a parent's direct children, returning the marker's `index` and the
-+ * walk-up `depth` (number of `parentElement` hops). Iterates siblings
-+ * last-to-first — the marker sits between `{children}` and `{separator}`
-+ * inside the ViewHolder, so it's near the end. Returns `null` if no
-+ * marker is found before reaching `root`.
-+ */
-+function findFocusedIndexFromMarker(target, root) {
-+ var _a;
-+ let current = target;
-+ let depth = 0;
-+ while (current && current !== root) {
-+ const parent = current.parentElement;
-+ if (!parent)
-+ break;
-+ for (let i = parent.children.length - 1; i >= 0; i--) {
-+ const child = parent.children[i];
-+ const idxStr = (_a = child.dataset) === null || _a === void 0 ? void 0 : _a.flashlistIndex;
-+ if (idxStr != null) {
-+ return { index: Number(idxStr), depth };
-+ }
-+ }
-+ current = parent;
-+ depth++;
-+ }
-+ return null;
-+}
- /**
- * ViewHolderCollection component that manages the rendering of multiple ViewHolder instances
- * and handles layout updates for the entire collection
- * @template TItem - The type of items in the data array
- */
- export const ViewHolderCollection = (props) => {
-- const { data, renderStack, getLayout, refHolder, onSizeChanged, renderItem, extraData, viewHolderCollectionRef, getChildContainerLayout, onCommitLayoutEffect, CellRendererComponent, ItemSeparatorComponent, onCommitEffect, horizontal, getAdjustmentMargin, currentStickyIndex, hideStickyHeaderRelatedCell, isInLastRow, inverted, } = props;
-+ const { data, renderStack, getLayout, refHolder, onSizeChanged, renderItem, extraData, viewHolderCollectionRef, getChildContainerLayout, onCommitLayoutEffect, CellRendererComponent, ItemSeparatorComponent, onCommitEffect, horizontal, getAdjustmentMargin, currentStickyIndex, hideStickyHeaderRelatedCell, isInLastRow, inverted, isScrollingProgrammatically, isScrolling, runAfterProgrammaticScroll, getLastScrollTime, } = props;
- const [renderId, setRenderId] = React.useState(0);
- const containerLayout = getChildContainerLayout();
- const fixedContainerSize = horizontal
-@@ -72,9 +136,160 @@ export const ViewHolderCollection = (props) => {
- // return `${index} => ${reactKey}`;
- // })
- // );
-- return (React.createElement(CompatView, { style: hasData && containerStyle }, containerLayout &&
-+ const containerRef = useRef(null);
-+ const lastFocusTimeRef = useRef(0);
-+ const lastFocusedIndexRef = useRef(null);
-+ const lastFocusedDepthRef = useRef(null);
-+ const shouldSortOnNextFocusRef = useRef(false);
-+ const renderEntriesRef = useRef(Array.from(renderStack.entries()));
-+ // Tracks the modality of the user's most recent input ("pointer" vs
-+ // "keyboard"). Pointer interactions defer the sync sort to avoid
-+ // re-rendering between `mousedown` and `click` (which makes the browser
-+ // drop the click).
-+ const lastInputModalityRef = useRef("pointer");
-+ const [, bumpSortVersion] = useReducer((x) => x + 1, 0);
-+ const sortItems = useCallback(() => {
-+ const entries = renderEntriesRef.current;
-+ const direction = inverted ? -1 : 1;
-+ const isSorted = entries.every((entry, i) => i === 0 || direction * (entries[i - 1][1].index - entry[1].index) <= 0);
-+ if (isSorted) {
-+ return;
-+ }
-+ entries.sort(([, a], [, b]) => direction * (a.index - b.index));
-+ bumpSortVersion();
-+ }, [inverted]);
-+ const [schedulePendingSort, clearPendingSort] = useDeferredCallback(sortItems, SORT_DELAY_MS, isScrolling);
-+ const maybeDoSortOnFocus = useCallback(() => {
-+ clearPendingSort();
-+ if (isScrollingProgrammatically()) {
-+ runAfterProgrammaticScroll(schedulePendingSort);
-+ return;
-+ }
-+ // Pointer-driven focus: defer the sync sort so we don't reorder the
-+ // DOM between `mousedown` and `click` (the browser would drop the
-+ // click). The pending sort will commit later via the timer.
-+ if (shouldSortOnNextFocusRef.current &&
-+ lastInputModalityRef.current === "pointer") {
-+ schedulePendingSort();
-+ return;
-+ }
-+ if (shouldSortOnNextFocusRef.current) {
-+ shouldSortOnNextFocusRef.current = false;
-+ sortItems();
-+ }
-+ schedulePendingSort();
-+ }, [
-+ isScrollingProgrammatically,
-+ runAfterProgrammaticScroll,
-+ schedulePendingSort,
-+ clearPendingSort,
-+ sortItems,
-+ ]);
-+ const maybeDoSortOnScroll = useCallback(() => {
-+ shouldSortOnNextFocusRef.current = true;
-+ // Evict any stale timer from a previous scroll's drain so it can't
-+ // fire mid-scroll during rapid-fire arrow nav (where `isMomentumEnd`
-+ // doesn't fire between key presses).
-+ clearPendingSort();
-+ if (isScrollingProgrammatically()) {
-+ runAfterProgrammaticScroll(schedulePendingSort);
-+ return;
-+ }
-+ if (isScrolling()) {
-+ // Focus-induced auto-scroll-into-view: sort sync to keep DOM
-+ // aligned for the next Tab. User-driven scrolls (negative Δ or Δ
-+ // past the window) defer to avoid sorting mid-mousewheel.
-+ const scrollSinceFocus = getLastScrollTime() - lastFocusTimeRef.current;
-+ const scrollNow = scrollSinceFocus >= 0 &&
-+ scrollSinceFocus < FOCUS_INDUCED_SCROLL_WINDOW_MS;
-+ if (scrollNow) {
-+ sortItems();
-+ shouldSortOnNextFocusRef.current = false;
-+ return;
-+ }
-+ }
-+ schedulePendingSort();
-+ }, [
-+ isScrollingProgrammatically,
-+ isScrolling,
-+ runAfterProgrammaticScroll,
-+ schedulePendingSort,
-+ clearPendingSort,
-+ sortItems,
-+ getLastScrollTime,
-+ ]);
-+ if (Platform.OS === "web") {
-+ // Reconcile: remove stale keys, append new keys
-+ const existingKeys = new Set(renderEntriesRef.current.map(([key]) => key));
-+ renderEntriesRef.current = renderEntriesRef.current.filter(([key]) => renderStack.has(key));
-+ for (const key of renderStack.keys()) {
-+ if (!existingKeys.has(key)) {
-+ renderEntriesRef.current.push([key, renderStack.get(key)]);
-+ }
-+ }
-+ }
-+ else {
-+ renderEntriesRef.current = Array.from(renderStack.entries());
-+ }
-+ useEffect(() => {
-+ const container = containerRef.current;
-+ if (Platform.OS !== "web" || !container) {
-+ return;
-+ }
-+ const onFocusIn = (e) => {
-+ var _a, _b;
-+ // Filter spurious focusins (recycle re-focus, mutation-phase
-+ // phantoms).
-+ const focused = findFocusedIndexFromMarker(e.target, containerRef.current);
-+ const focusedIndex = (_a = focused === null || focused === void 0 ? void 0 : focused.index) !== null && _a !== void 0 ? _a : null;
-+ const focusedDepth = (_b = focused === null || focused === void 0 ? void 0 : focused.depth) !== null && _b !== void 0 ? _b : null;
-+ const isSameLogicalRow = focusedIndex !== null &&
-+ focusedIndex === lastFocusedIndexRef.current &&
-+ focusedDepth === lastFocusedDepthRef.current;
-+ const isPhantomMutationFocus = e.relatedTarget === null && focusedIndex !== null;
-+ if (isSameLogicalRow || isPhantomMutationFocus) {
-+ return;
-+ }
-+ lastFocusedIndexRef.current = focusedIndex;
-+ lastFocusedDepthRef.current = focusedDepth;
-+ lastFocusTimeRef.current = Date.now();
-+ maybeDoSortOnFocus();
-+ };
-+ container.addEventListener("focusin", onFocusIn);
-+ return () => container.removeEventListener("focusin", onFocusIn);
-+ }, [maybeDoSortOnFocus]);
-+ useEffect(() => {
-+ if (Platform.OS !== "web") {
-+ return;
-+ }
-+ maybeDoSortOnScroll();
-+ return clearPendingSort;
-+ // eslint-disable-next-line react-hooks/exhaustive-deps
-+ }, [renderStack, renderId]);
-+ // Track input modality globally. Capture-phase document listeners so
-+ // we observe events before any handler can call `stopPropagation()`.
-+ // `pointerdown` covers mouse/touch/pen; `keydown` covers Tab and
-+ // assistive technologies (e.g. VoiceOver injects keydowns).
-+ useEffect(() => {
-+ if (Platform.OS !== "web") {
-+ return;
-+ }
-+ const onDocKeyDown = () => {
-+ lastInputModalityRef.current = "keyboard";
-+ };
-+ const onDocPointerDown = () => {
-+ lastInputModalityRef.current = "pointer";
-+ };
-+ document.addEventListener("keydown", onDocKeyDown, true);
-+ document.addEventListener("pointerdown", onDocPointerDown, true);
-+ return () => {
-+ document.removeEventListener("keydown", onDocKeyDown, true);
-+ document.removeEventListener("pointerdown", onDocPointerDown, true);
-+ };
-+ }, []);
-+ return (React.createElement(CompatView, { ref: containerRef, style: hasData && containerStyle }, containerLayout &&
- hasData &&
-- Array.from(renderStack.entries(), ([reactKey, { index }]) => {
-+ renderEntriesRef.current.map(([reactKey, { index }]) => {
- const item = data[index];
- // Suppress separators for items in the last row to prevent
- // height mismatch. The last data item has no separator (no
-diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.d.ts b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.d.ts
-index 62d55cd..b715484 100644
---- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.d.ts
-+++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.d.ts
-@@ -24,5 +24,12 @@ export declare function useRecyclerViewController(recyclerViewManager: Recycl
- computeFirstVisibleIndexForOffsetCorrection: () => void;
- applyInitialScrollIndex: () => void;
- handlerMethods: FlashListRef;
-+ isScrollingProgrammatically: () => boolean;
-+ isScrolling: () => boolean;
-+ runAfterProgrammaticScroll: (cb: () => void) => void;
-+ notifyProgrammaticScrollSettled: () => void;
-+ notifyScrollActive: () => void;
-+ notifyScrollSettled: () => void;
-+ getLastScrollTime: () => number;
- };
- //# sourceMappingURL=useRecyclerViewController.d.ts.map
-\ No newline at end of file
-diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js
-index 165b080..18e59ce 100644
---- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js
-+++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js
-@@ -25,6 +25,21 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe
- const isUnmounted = useUnmountFlag();
- const [_, setRenderId] = useState(0);
- const pauseOffsetCorrection = useRef(false);
-+ // True while a `scrollToIndex` / `scrollToOffset` smooth scroll is in
-+ // flight. Cleared exactly once on `isMomentumEnd` via
-+ // `notifyProgrammaticScrollSettled`.
-+ const isProgrammaticScrollActiveRef = useRef(false);
-+ // Set by `announceProgrammaticScroll()` to announce an imminent scroll.
-+ // Handed off to `isProgrammaticScrollActiveRef` at `scrollToIndex` entry.
-+ const isProgrammaticScrollQueuedRef = useRef(false);
-+ // Source-agnostic "viewport in motion" flag.
-+ const isScrollingRef = useRef(false);
-+ // Timestamp of the most recent scroll event; used to correlate scroll
-+ // and focus events for the focus-induced-scroll heuristic.
-+ const lastScrollTimeRef = useRef(0);
-+ // Holds at most one callback registered via `runAfterProgrammaticScroll`,
-+ // drained from `notifyProgrammaticScrollSettled`.
-+ const pendingAfterScrollRef = useRef(null);
- const pendingAndroidInvertedRafId = useRef(null);
- const skipNextAndroidInvertedCorrection = useRef(false);
- const lastDataLengthRef = useRef(recyclerViewManager.getDataLength());
-@@ -180,6 +195,33 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe
- updateScrollOffsetWithCallback,
- computeFirstVisibleIndexForOffsetCorrection,
- ]);
-+ const isScrollingProgrammatically = useCallback(() => isProgrammaticScrollActiveRef.current ||
-+ isProgrammaticScrollQueuedRef.current, []);
-+ const isScrolling = useCallback(() => isScrollingRef.current, []);
-+ const runAfterProgrammaticScroll = useCallback((cb) => {
-+ pendingAfterScrollRef.current = cb;
-+ }, []);
-+ // Public API; see `FlashListRef#announceProgrammaticScroll`.
-+ const announceProgrammaticScroll = useCallback(() => {
-+ isProgrammaticScrollQueuedRef.current = true;
-+ }, []);
-+ // Invoked from `RecyclerView.onScrollHandler` on `isMomentumEnd` (~100ms
-+ // after the last scroll event). Drains the pending callback if any.
-+ const notifyProgrammaticScrollSettled = useCallback(() => {
-+ isProgrammaticScrollActiveRef.current = false;
-+ isProgrammaticScrollQueuedRef.current = false;
-+ const cb = pendingAfterScrollRef.current;
-+ pendingAfterScrollRef.current = null;
-+ cb === null || cb === void 0 ? void 0 : cb();
-+ }, []);
-+ const notifyScrollActive = useCallback(() => {
-+ isScrollingRef.current = true;
-+ lastScrollTimeRef.current = Date.now();
-+ }, []);
-+ const notifyScrollSettled = useCallback(() => {
-+ isScrollingRef.current = false;
-+ }, []);
-+ const getLastScrollTime = useCallback(() => lastScrollTimeRef.current, []);
- const handlerMethods = useMemo(() => {
- return {
- get props() {
-@@ -271,6 +313,11 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe
- animated,
- });
- },
-+ /**
-+ * Announces an imminent programmatic scroll. See
-+ * `FlashListRef#announceProgrammaticScroll` for full semantics.
-+ */
-+ announceProgrammaticScroll,
- /**
- * Scrolls to a specific index in the list.
- * Supports viewPosition and viewOffset for precise positioning.
-@@ -292,6 +339,11 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe
- // Pause the scroll offset adjustments
- pauseOffsetCorrection.current = true;
- recyclerViewManager.setOffsetProjectionEnabled(false);
-+ // Cleared on `isMomentumEnd` via `notifyProgrammaticScrollSettled`.
-+ // Hand off "queued" → "active" here so any stale queue flag
-+ // can't gate sorts indefinitely.
-+ isProgrammaticScrollQueuedRef.current = false;
-+ isProgrammaticScrollActiveRef.current = true;
- const getFinalOffset = () => {
- const layout = recyclerViewManager.getLayout(index);
- const offset = horizontal ? layout.x : layout.y;
-@@ -496,6 +548,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe
- setTimeout,
- isUnmounted,
- updateScrollOffsetWithCallback,
-+ announceProgrammaticScroll,
- ]);
- const applyInitialScrollIndex = useCallback(() => {
- var _a, _b, _c;
-@@ -550,6 +603,13 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe
- computeFirstVisibleIndexForOffsetCorrection,
- applyInitialScrollIndex,
- handlerMethods,
-+ isScrollingProgrammatically,
-+ isScrolling,
-+ runAfterProgrammaticScroll,
-+ notifyProgrammaticScrollSettled,
-+ notifyScrollActive,
-+ notifyScrollSettled,
-+ getLastScrollTime,
- };
- }
- //# sourceMappingURL=useRecyclerViewController.js.map
-\ No newline at end of file
-diff --git a/node_modules/@shopify/flash-list/src/FlashListRef.ts b/node_modules/@shopify/flash-list/src/FlashListRef.ts
-index 07bac2a..af9ee7d 100644
---- a/node_modules/@shopify/flash-list/src/FlashListRef.ts
-+++ b/node_modules/@shopify/flash-list/src/FlashListRef.ts
-@@ -181,6 +181,31 @@ export interface FlashListRef {
- */
- scrollToIndex: (params: ScrollToIndexParams) => Promise;
-
-+ /**
-+ * Announces an imminent programmatic scroll before `scrollToIndex` is
-+ * actually called, so DOM-mutating side-effects gated on
-+ * `isScrollingProgrammatically()` (notably the on-web sort applied by
-+ * `ViewHolderCollection`) defer until the upcoming smooth scroll
-+ * settles, rather than running synchronously and cancelling it.
-+ *
-+ * Useful when the focus assignment happens first and `scrollToIndex`
-+ * follows a few ticks later — as long as the call is guaranteed to
-+ * happen, queue it up front so the intervening `focusin` doesn't
-+ * trigger an immediate sort that the smooth scroll would then cancel.
-+ *
-+ * Cleared automatically when the next `scrollToIndex` is invoked
-+ * (handed off to the in-flight flag) and again when the resulting
-+ * scroll's momentum ends. Safe to call multiple times.
-+ *
-+ * @example
-+ * listRef.current?.announceProgrammaticScroll();
-+ * itemDomNode.focus();
-+ * setTimeout(() => {
-+ * listRef.current?.scrollToIndex({ index: nextIndex, animated: true });
-+ * }, 0);
-+ */
-+ announceProgrammaticScroll: () => void;
-+
- /**
- * Scrolls to a specific item in the list.
- *
diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+012+fix-scrollbar-oscillation-crash.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+012+fix-scrollbar-oscillation-crash.patch
deleted file mode 100644
index 03a1dbf1249d..000000000000
--- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+012+fix-scrollbar-oscillation-crash.patch
+++ /dev/null
@@ -1,126 +0,0 @@
-diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js b/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js
-index 12375d9..bfc3ee2 100644
---- a/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js
-+++ b/node_modules/@shopify/flash-list/dist/recyclerview/layout-managers/LinearLayoutManager.js
-@@ -1,4 +1,13 @@
-+import { Platform } from "react-native";
- import { RVLayoutManager, } from "./LayoutManager";
-+// How many recent widths we keep while watching for a scrollbar flicker.
-+const BOUNDED_SIZE_HISTORY_LENGTH = 8;
-+// One scrollbar toggle only flips twice, so three means it's bouncing.
-+const MIN_OSCILLATION_FLIPS = 3;
-+// Scrollbars are ~15-17px. A jump this small is a scrollbar, not a resize.
-+const SCROLLBAR_OSCILLATION_TOLERANCE = 25;
-+// How many times we trust the wider width before we keep the lock instead.
-+const MAX_LOCK_RELEASE_CYCLES = 1;
- /**
- * LinearLayoutManager implementation that arranges items in a single row or column.
- * Supports both horizontal and vertical layouts with dynamic item sizing.
-@@ -10,6 +19,8 @@
- this.hasSize = false;
- /** Height of the tallest item */
- this.tallestItemHeight = 0;
-+ /** How many times the scrollbar lock has been released for the current pair of widths */
-+ this.scrollbarLockReleases = 0;
- this.boundedSize = this.horizontal
- ? params.windowSize.height
- : params.windowSize.width;
-@@ -23,9 +34,15 @@
- const prevHorizontal = this.horizontal;
- super.updateLayoutParams(params);
- const oldBoundedSize = this.boundedSize;
-- this.boundedSize = this.horizontal
-+ const measuredBoundedSize = this.horizontal
- ? params.windowSize.height
- : params.windowSize.width;
-+ // On web, a scrollbar showing and hiding can kick off an endless re-layout loop that
-+ // crashes the app. Native scrollbars float on top and never do this, so only guard on web.
-+ this.boundedSize =
-+ Platform.OS === "web"
-+ ? this.settleScrollbarOscillation(measuredBoundedSize)
-+ : measuredBoundedSize;
- if (oldBoundedSize !== this.boundedSize ||
- prevHorizontal !== this.horizontal) {
- if (this.layouts.length > 0) {
-@@ -36,6 +53,81 @@
- }
- }
- /**
-+ * Web only. Stops the re-layout loop caused by a scrollbar that keeps showing and hiding.
-+ *
-+ * The list measures its width without the scrollbar, so each toggle changes the width, which
-+ * relayouts, which changes the height, which toggles the scrollbar again. React eventually
-+ * gives up with "Maximum update depth exceeded" (#185).
-+ *
-+ * We watch for a width bouncing between two values a scrollbar apart and lock to the smaller
-+ * one. The toggle can lag a frame, so the bounce is often A,A,B,B rather than A,B,A,B. A real
-+ * resize passes through many widths and never looks like this.
-+ *
-+ * The lock has to survive the wider width. These lists get shorter as they narrow, so once we
-+ * lock, the scrollbar goes away and the wider width comes right back. Letting go every time
-+ * just slows the loop down.
-+ * @param measuredBoundedSize Cross-axis size we just measured
-+ * @returns The cross-axis size to lay out with
-+ */
-+ settleScrollbarOscillation(measuredBoundedSize) {
-+ var _a;
-+ // Round to whole pixels so subpixel drift doesn't break the checks below.
-+ const size = Math.round(measuredBoundedSize);
-+ const settledPair = this.scrollbarOscillationPair;
-+ if (settledPair) {
-+ const [smaller, larger] = settledPair;
-+ // Still on the smaller width, so the scrollbar is still there. Keep the lock.
-+ if (size === smaller) {
-+ return smaller;
-+ }
-+ if (size === larger) {
-+ // Wider again. Either the scrollbar went away for good (happens once) or the
-+ // flicker is still going (happens every round). Trust it once, then stop.
-+ // Not reset on re-lock, or a flicker would top it up forever.
-+ this.scrollbarLockReleases++;
-+ if (this.scrollbarLockReleases > MAX_LOCK_RELEASE_CYCLES) {
-+ return smaller;
-+ }
-+ }
-+ else {
-+ // Some other width, so this is a real resize and the pair is stale.
-+ this.scrollbarLockReleases = 0;
-+ }
-+ this.scrollbarOscillationPair = undefined;
-+ this.recentBoundedSizes = undefined;
-+ }
-+ const history = ((_a = this.recentBoundedSizes) !== null && _a !== void 0 ? _a : (this.recentBoundedSizes = []));
-+ history.push(size);
-+ if (history.length > BOUNDED_SIZE_HISTORY_LENGTH) {
-+ history.shift();
-+ }
-+ const distinctSizes = Array.from(new Set(history));
-+ if (distinctSizes.length !== 2 ||
-+ Math.abs(distinctSizes[0] - distinctSizes[1]) > SCROLLBAR_OSCILLATION_TOLERANCE) {
-+ return measuredBoundedSize;
-+ }
-+ let flips = 0;
-+ for (let i = 1; i < history.length; i++) {
-+ if (history[i] !== history[i - 1]) {
-+ flips++;
-+ }
-+ }
-+ if (flips < MIN_OSCILLATION_FLIPS) {
-+ return measuredBoundedSize;
-+ }
-+ const smaller = Math.min(distinctSizes[0], distinctSizes[1]);
-+ // Only lock on a smaller-width frame so we lock to the width that's on screen. An old
-+ // bounce can still be in the history, and locking then would shrink rows for no reason.
-+ if (size !== smaller) {
-+ return measuredBoundedSize;
-+ }
-+ this.scrollbarOscillationPair = [
-+ smaller,
-+ Math.max(distinctSizes[0], distinctSizes[1]),
-+ ];
-+ return smaller;
-+ }
-+ /**
- * Processes layout information for items, updating their dimensions.
- * For horizontal layouts, also normalizes heights of items.
- * @param layoutInfo Array of layout information for items
diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch
deleted file mode 100644
index 521561d5ef2b..000000000000
--- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch
+++ /dev/null
@@ -1,166 +0,0 @@
-diff --git a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts
-index fa786bf..586014c 100644
---- a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts
-+++ b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts
-@@ -127,10 +127,12 @@ export interface FlashListProps extends Omit 0) {
-+ initialItemOffset = Math.max(0, initialItemOffset - (windowSize - itemSize) * viewPosition);
-+ }
-+ }
- this.engagedIndicesTracker.scrollOffset = initialItemOffset;
- }
- else {
-@@ -317,8 +332,20 @@ export class RecyclerViewManager {
- this.applyInitialScrollAdjustment();
- const visibleIndices = this.computeVisibleIndices();
- // console.log("---------> visibleIndices", visibleIndices);
-- this.hasRenderedProgressively = visibleIndices.every((index) => layoutManager.getLayout(index).isHeightMeasured &&
-- layoutManager.getLayout(index).isWidthMeasured);
-+ const isFullyMeasured = (index) => layoutManager.getLayout(index).isHeightMeasured &&
-+ layoutManager.getLayout(index).isWidthMeasured;
-+ // With an explicit initialScrollIndex, also wait for the drawDistance buffer to be measured before
-+ // completing the first layout, so estimate-driven layout shifts converge before anything is on screen.
-+ let targetIndices = visibleIndices;
-+ if (this.propsRef.initialScrollIndex !== undefined && visibleIndices.length > 0 && visibleIndices.every(isFullyMeasured)) {
-+ const windowSize = this.propsRef.horizontal ? this.getWindowSize().width : this.getWindowSize().height;
-+ const viewportStart = this.engagedIndicesTracker.scrollOffset;
-+ // Cover the worst-case one-sided buffer the engaged tracker can mount after
-+ // first layout (totalBuffer * largeMultiplier in the scroll direction).
-+ const bufferDistance = this.engagedIndicesTracker.drawDistance * 2 * this.engagedIndicesTracker.largeMultiplier;
-+ targetIndices = layoutManager.getVisibleLayouts(Math.max(0, viewportStart - bufferDistance), viewportStart + windowSize + bufferDistance);
-+ }
-+ this.hasRenderedProgressively = targetIndices.every(isFullyMeasured);
- if (this.hasRenderedProgressively) {
- this.isFirstLayoutComplete = true;
- }
-@@ -327,9 +354,13 @@ export class RecyclerViewManager {
- // If everything is measured then render stack will be in sync. The buffer items will get rendered in the next update
- // triggered by the useOnLoad hook.
- !this.hasRenderedProgressively &&
-- this.updateRenderStack(
-- // pick first n indices from visible ones based on batch size
-- visibleIndices.slice(0, Math.min(visibleIndices.length, this.getRenderStack().size + batchSize)));
-+ this.updateRenderStack(targetIndices === visibleIndices
-+ ? // pick first n indices from visible ones based on batch size
-+ visibleIndices.slice(0, Math.min(visibleIndices.length, this.getRenderStack().size + batchSize))
-+ : // buffer phase: visible items are already measured, mount the whole
-+ // buffer window at once. Same single-commit cost the engaged tracker
-+ // would pay post-paint, just moved to where nothing is visible yet.
-+ targetIndices);
- }
- }
- getItemType(index) {
-diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js
-index 18e59ce..40bdddb 100644
---- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js
-+++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js
-@@ -25,6 +25,10 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe
- const isUnmounted = useUnmountFlag();
- const [_, setRenderId] = useState(0);
- const pauseOffsetCorrection = useRef(false);
-+ // Latest offset computed by applyInitialScrollIndex. The deferred (setTimeout) re-scroll reads this at
-+ // fire-time instead of the value it closed over, so a stale timeout scheduled by an earlier commit can't
-+ // snap back to an outdated offset after a newer commit.
-+ const latestInitialScrollOffsetRef = useRef(0);
- // True while a `scrollToIndex` / `scrollToOffset` smooth scroll is in
- // flight. Cleared exactly once on `isMomentumEnd` via
- // `notifyProgrammaticScrollSettled`.
-@@ -566,18 +570,55 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe
- }, 500);
- pauseOffsetCorrection.current = true;
- const additionalOffset = (_c = initialScrollIndexParams === null || initialScrollIndexParams === void 0 ? void 0 : initialScrollIndexParams.viewOffset) !== null && _c !== void 0 ? _c : 0;
-- const offset = horizontal
-- ? recyclerViewManager.getLayout(initialScrollIndex).x + additionalOffset
-- : recyclerViewManager.getLayout(initialScrollIndex).y +
-- additionalOffset;
-+ const initialItemLayout = recyclerViewManager.getLayout(initialScrollIndex);
-+ let offset = (horizontal ? initialItemLayout.x : initialItemLayout.y) +
-+ additionalOffset;
-+ // Position the target item within the viewport (0 = start, 0.5 = center, 1 = end), mirroring scrollToIndex.
-+ const viewPosition = initialScrollIndexParams === null || initialScrollIndexParams === void 0 ? void 0 : initialScrollIndexParams.viewPosition;
-+ if (viewPosition !== undefined) {
-+ const containerSize = horizontal
-+ ? recyclerViewManager.getWindowSize().width
-+ : recyclerViewManager.getWindowSize().height;
-+ const itemSize = horizontal
-+ ? initialItemLayout.width
-+ : initialItemLayout.height;
-+ if (containerSize > 0) {
-+ offset = Math.max(0, offset - (containerSize - itemSize) * viewPosition);
-+ }
-+ }
-+ // Make it clear there are more items to scroll to underneath the bottom edge.
-+ // If the bottom item is (essentially) fully visible against the bottom edge AND there
-+ // is an item underneath it, nudge the bottom edge up so CROP_OFFSET px of the current
-+ // bottom item gets cropped, signalling that more content can be scrolled into view.
-+ if (viewPosition !== undefined && !horizontal && recyclerViewManager.props.inverted && offset > 0) {
-+ const CROP_OFFSET = 10;
-+ let bottomIndex = -1;
-+ for (let i = initialScrollIndex; i >= 0; i--) {
-+ if (recyclerViewManager.getLayout(i).y <= offset) {
-+ bottomIndex = i;
-+ break;
-+ }
-+ }
-+ if (bottomIndex > 0) {
-+ const bottomItemLayout = recyclerViewManager.getLayout(bottomIndex);
-+ const hiddenPortion = offset - bottomItemLayout.y;
-+ // 8px is bottom padding of every item
-+ if (hiddenPortion <= 8) {
-+ // Crop the current bottom item rather than letting it sit flush against the edge.
-+ offset = bottomItemLayout.y + CROP_OFFSET;
-+ }
-+ }
-+ }
-+ latestInitialScrollOffsetRef.current = offset;
- handlerMethods.scrollToOffset({
- offset,
- animated: false,
- skipFirstItemOffset: false,
- });
-+
- setTimeout(() => {
- handlerMethods.scrollToOffset({
-- offset,
-+ offset: latestInitialScrollOffsetRef.current,
- animated: false,
- skipFirstItemOffset: false,
- });
diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+014+external-window-size.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+014+external-window-size.patch
deleted file mode 100644
index 971a05fa078b..000000000000
--- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+014+external-window-size.patch
+++ /dev/null
@@ -1,104 +0,0 @@
-diff --git a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts
-index fa786bf..014eb62 100644
---- a/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts
-+++ b/node_modules/@shopify/flash-list/dist/FlashListProps.d.ts
-@@ -99,6 +99,15 @@ export interface FlashListProps extends Omit | React.ExoticComponent | React.FC;
-+ /**
-+ * When set, the list uses this as its visible window size instead of measuring its outer container.
-+ * Intended for externally-driven lists (a custom non-scrolling `renderScrollComponent` fed synthetic scroll
-+ * events), where the outer container is as tall as the full content and can't be used as the viewport.
-+ */
-+ overrideWindowSize?: {
-+ width: number;
-+ height: number;
-+ };
- /**
- * Draw distance for advanced rendering (in dp/px)
- */
-diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
-index ef4daf2..063fd1a 100644
---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
-+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
-@@ -28,7 +28,7 @@ import { RenderTimeTracker } from "./helpers/RenderTimeTracker";
- const RecyclerViewComponent = (props, ref) => {
- var _a, _b, _c, _d;
- // Destructure props and initialize refs
-- const { horizontal, renderItem, data, extraData, onLoad, CellRendererComponent, overrideProps, refreshing, onRefresh, progressViewOffset, ListEmptyComponent, ListHeaderComponent, ListHeaderComponentStyle, ListFooterComponent, ListFooterComponentStyle, ItemSeparatorComponent, renderScrollComponent, style, stickyHeaderIndices, maintainVisibleContentPosition, onCommitLayoutEffect, onChangeStickyIndex, stickyHeaderConfig, inverted, ...rest } = props;
-+ const { horizontal, renderItem, data, extraData, onLoad, CellRendererComponent, overrideProps, refreshing, onRefresh, progressViewOffset, ListEmptyComponent, ListHeaderComponent, ListHeaderComponentStyle, ListFooterComponent, ListFooterComponentStyle, ItemSeparatorComponent, renderScrollComponent, style, stickyHeaderIndices, maintainVisibleContentPosition, onCommitLayoutEffect, onChangeStickyIndex, stickyHeaderConfig, inverted, overrideWindowSize, ...rest } = props;
- const [renderTimeTracker] = useState(() => new RenderTimeTracker());
- renderTimeTracker.startTracking();
- // Sticky header config
-@@ -147,12 +147,15 @@ const RecyclerViewComponent = (props, ref) => {
- : horizontal
- ? firstChildViewLayout.x
- : firstChildViewLayout.y;
-+ // overrideWindowSize lets an externally-driven list (no own scroller, container is as tall as the full
-+ // content) declare its visible window; everything else still uses the real measurement.
-+ const windowSize = overrideWindowSize !== null && overrideWindowSize !== void 0 ? overrideWindowSize : outerViewSize;
- // Update the RecyclerView manager with window dimensions
- recyclerViewManager.updateLayoutParams({
-- width: horizontal ? outerViewSize.width : firstChildViewLayout.width,
-+ width: horizontal ? windowSize.width : firstChildViewLayout.width,
- height: horizontal
- ? firstChildViewLayout.height
-- : outerViewSize.height,
-+ : windowSize.height,
- }, isHorizontalRTL && recyclerViewManager.hasLayout()
- ? firstItemOffset -
- recyclerViewManager.getChildContainerDimensions().width
-diff --git a/node_modules/@shopify/flash-list/src/FlashListProps.ts b/node_modules/@shopify/flash-list/src/FlashListProps.ts
-index 76dd0c8..5a5c0f5 100644
---- a/node_modules/@shopify/flash-list/src/FlashListProps.ts
-+++ b/node_modules/@shopify/flash-list/src/FlashListProps.ts
-@@ -160,6 +160,16 @@ export interface FlashListProps
- | React.ExoticComponent
- | React.FC;
-
-+ /**
-+ * When set, the list uses this as its visible window size instead of measuring its outer container.
-+ * Intended for externally-driven lists (a custom non-scrolling `renderScrollComponent` fed synthetic scroll
-+ * events), where the outer container is as tall as the full content and can't be used as the viewport.
-+ */
-+ overrideWindowSize?: {
-+ width: number;
-+ height: number;
-+ };
-+
- /**
- * Draw distance for advanced rendering (in dp/px)
- */
-diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx
-index bc27739..a7829d5 100644
---- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx
-+++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx
-@@ -90,6 +90,7 @@ const RecyclerViewComponent = (
- onChangeStickyIndex,
- stickyHeaderConfig,
- inverted,
-+ overrideWindowSize,
- ...rest
- } = props;
-
-@@ -265,13 +266,17 @@ const RecyclerViewComponent = (
- ? firstChildViewLayout.x
- : firstChildViewLayout.y;
-
-+ // overrideWindowSize lets an externally-driven list (no own scroller, container is as tall as the full
-+ // content) declare its visible window; everything else still uses the real measurement.
-+ const windowSize = overrideWindowSize ?? outerViewSize;
-+
- // Update the RecyclerView manager with window dimensions
- recyclerViewManager.updateLayoutParams(
- {
-- width: horizontal ? outerViewSize.width : firstChildViewLayout.width,
-+ width: horizontal ? windowSize.width : firstChildViewLayout.width,
- height: horizontal
- ? firstChildViewLayout.height
-- : outerViewSize.height,
-+ : windowSize.height,
- },
- isHorizontalRTL && recyclerViewManager.hasLayout()
- ? firstItemOffset -
diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch
deleted file mode 100644
index 5d59a58de2ff..000000000000
--- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch
+++ /dev/null
@@ -1,358 +0,0 @@
-diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
-index 063fd1a..c37ff3a 100644
---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
-+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
-@@ -349,8 +349,27 @@ const RecyclerViewComponent = (props, ref) => {
- recyclerViewContext.layout();
- }
- }, [recyclerViewContext, recyclerViewManager]);
-+ // The ListHeaderComponent can resize on its own (async content settling inside it) without this component
-+ // re-rendering — firstItemOffset is only re-measured in this component's layout effects, so items would keep
-+ // stale on-screen positions and applyOffsetCorrection would never see the header delta. Trigger a layout pass
-+ // when the header's main-axis size changes. Inverted lists skip this: firstItemOffset is forced to 0 there,
-+ // so a header resize cannot shift item positions.
-+ const lastHeaderSizeRef = useRef(-1);
-+ const onHeaderLayout = useCallback((event) => {
-+ if (inverted) {
-+ return;
-+ }
-+ const headerSize = horizontal
-+ ? event.nativeEvent.layout.width
-+ : event.nativeEvent.layout.height;
-+ if (lastHeaderSizeRef.current >= 0 &&
-+ areDimensionsNotEqual(lastHeaderSizeRef.current, headerSize)) {
-+ recyclerViewContext.layout();
-+ }
-+ lastHeaderSizeRef.current = headerSize;
-+ }, [horizontal, inverted, recyclerViewContext]);
- // Get secondary props and components
-- const { refreshControl, renderHeader, renderFooter, renderEmpty, CompatScrollView, renderStickyHeaderBackdrop, } = useSecondaryProps(props);
-+ const { refreshControl, renderHeader, renderFooter, renderEmpty, CompatScrollView, renderStickyHeaderBackdrop, } = useSecondaryProps(props, onHeaderLayout);
- if (!recyclerViewManager.getIsFirstLayoutComplete() &&
- recyclerViewManager.getDataLength() > 0) {
- parentRecyclerViewContext === null || parentRecyclerViewContext === void 0 ? void 0 : parentRecyclerViewContext.markChildLayoutAsPending(recyclerViewId);
-diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js
-index 40bdddb..c505e59 100644
---- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js
-+++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useRecyclerViewController.js
-@@ -51,6 +51,10 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe
- // Track the first visible item for maintaining scroll position
- const firstVisibleItemKey = useRef(undefined);
- const firstVisibleItemLayout = useRef(undefined);
-+ // firstItemOffset (the ListHeaderComponent's size) at the time the anchor above was captured. Item layouts are
-+ // header-relative, so a header resize shifts every item on screen without changing any tracked x/y — the delta
-+ // must be captured separately or offset correction is blind to it.
-+ const firstVisibleItemFirstItemOffset = useRef(0);
- // Queue to store callbacks that should be executed after scroll offset updates
- const pendingScrollCallbacks = useRef([]);
- // Handle initial scroll position when the list first loads
-@@ -82,6 +86,16 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe
- recyclerViewManager.hasStableDataKeys() &&
- recyclerViewManager.getDataLength() > 0 &&
- recyclerViewManager.shouldMaintainVisibleContentPosition()) {
-+ // When the viewport top sits inside the ListHeaderComponent, the header — not any data item — is the
-+ // user's visual anchor. The Math.max(0, startIndex) clamp below would otherwise anchor item 0 (which can
-+ // be far below the fold) and "maintain" its position through header resizes or data prepends, yanking
-+ // the viewport away from what the user is looking at. The header's own top never moves, so the correct
-+ // correction while it is the anchor is none — drop the tracked item instead.
-+ if (recyclerViewManager.getAbsoluteLastScrollOffset() <
-+ recyclerViewManager.firstItemOffset) {
-+ firstVisibleItemKey.current = undefined;
-+ return;
-+ }
- // Update the tracked first visible item
- const firstVisibleIndex = Math.max(0, recyclerViewManager.computeVisibleIndices().startIndex);
- if (firstVisibleIndex !== undefined && firstVisibleIndex >= 0) {
-@@ -90,6 +104,7 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe
- firstVisibleItemLayout.current = {
- ...recyclerViewManager.getLayout(firstVisibleIndex),
- };
-+ firstVisibleItemFirstItemOffset.current = recyclerViewManager.firstItemOffset;
- }
- }
- }, [recyclerViewManager]);
-@@ -128,15 +143,21 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe
- : undefined);
- if (currentIndexOfFirstVisibleItem !== undefined &&
- currentIndexOfFirstVisibleItem >= 0) {
-- // Calculate the difference in position and apply the offset
-- const diff = horizontal
-+ // Calculate the difference in position and apply the offset. Item layouts are header-relative,
-+ // so a ListHeaderComponent resize shifts every item on screen by the same amount without
-+ // changing any layout — it is only observable as a firstItemOffset delta, tracked separately.
-+ const layoutDiff = horizontal
- ? recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem).x -
- firstVisibleItemLayout.current.x
- : recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem).y -
- firstVisibleItemLayout.current.y;
-+ const diff = layoutDiff +
-+ (recyclerViewManager.firstItemOffset -
-+ firstVisibleItemFirstItemOffset.current);
- firstVisibleItemLayout.current = {
- ...recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem),
- };
-+ firstVisibleItemFirstItemOffset.current = recyclerViewManager.firstItemOffset;
- if (diff !== 0 &&
- !pauseOffsetCorrection.current &&
- !recyclerViewManager.animationOptimizationsEnabled) {
-@@ -147,13 +168,16 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe
- }
- else if (useAndroidInvertedFallback) {
- if (!shouldSkipAndroidInvertedCorrection) {
-+ // getAbsoluteLastScrollOffset() already reflects the current firstItemOffset (the
-+ // tracker's relative offset only resyncs on scroll events), so the header delta is
-+ // already embedded in it — add only the layout diff on scrollTo paths.
- const scrollToParams = horizontal
- ? {
-- x: recyclerViewManager.getAbsoluteLastScrollOffset() + diff,
-+ x: recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff,
- animated: false,
- }
- : {
-- y: recyclerViewManager.getAbsoluteLastScrollOffset() + diff,
-+ y: recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff,
- animated: false,
- };
- if (pendingAndroidInvertedRafId.current !== null) {
-@@ -169,17 +193,17 @@ export function useRecyclerViewController(recyclerViewManager, ref, scrollViewRe
- else {
- const scrollToParams = horizontal
- ? {
-- x: recyclerViewManager.getAbsoluteLastScrollOffset() + diff,
-+ x: recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff,
- animated: false,
- }
- : {
-- y: recyclerViewManager.getAbsoluteLastScrollOffset() + diff,
-+ y: recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff,
- animated: false,
- };
- (_c = scrollViewRef.current) === null || _c === void 0 ? void 0 : _c.scrollTo(scrollToParams);
- }
- if (hasDataChanged) {
-- updateScrollOffsetWithCallback(recyclerViewManager.getAbsoluteLastScrollOffset() + diff, () => { });
-+ updateScrollOffsetWithCallback(recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff, () => { });
- recyclerViewManager.ignoreScrollEvents = true;
- setTimeout(() => {
- recyclerViewManager.ignoreScrollEvents = false;
-diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useSecondaryProps.js b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useSecondaryProps.js
-index 4d7a945..26133d6 100644
---- a/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useSecondaryProps.js
-+++ b/node_modules/@shopify/flash-list/dist/recyclerview/hooks/useSecondaryProps.js
-@@ -21,7 +21,7 @@ import { getInvertedTransformStyle } from "../utils/getInvertedTransformStyle";
- * - renderStickyHeaderBackdrop: The sticky header backdrop component renderer
- * - CompatScrollView: The animated scroll component
- */
--export function useSecondaryProps(props) {
-+export function useSecondaryProps(props, onHeaderLayout) {
- const { ListHeaderComponent, ListHeaderComponentStyle, ListFooterComponent, ListFooterComponentStyle, ListEmptyComponent, ListEmptyComponentStyle, renderScrollComponent, refreshing, progressViewOffset, onRefresh, data, refreshControl: customRefreshControl, stickyHeaderConfig, inverted, horizontal, } = props;
- const invertedTransformStyle = inverted
- ? getInvertedTransformStyle(horizontal)
-@@ -45,8 +45,8 @@ export function useSecondaryProps(props) {
- if (!ListHeaderComponent) {
- return null;
- }
-- return (React.createElement(CompatView, { style: [ListHeaderComponentStyle, invertedTransformStyle] }, getValidComponent(ListHeaderComponent)));
-- }, [ListHeaderComponent, ListHeaderComponentStyle, invertedTransformStyle]);
-+ return (React.createElement(CompatView, { style: [ListHeaderComponentStyle, invertedTransformStyle], onLayout: onHeaderLayout }, getValidComponent(ListHeaderComponent)));
-+ }, [ListHeaderComponent, ListHeaderComponentStyle, invertedTransformStyle, onHeaderLayout]);
- /**
- * Creates the footer component with optional styling.
- */
-diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx
-index a7829d5..ec5d7b7 100644
---- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx
-+++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx
-@@ -16,6 +16,7 @@ import React, {
- import {
- Animated,
- I18nManager,
-+ LayoutChangeEvent,
- NativeScrollEvent,
- NativeSyntheticEvent,
- Platform,
-@@ -501,6 +502,31 @@ const RecyclerViewComponent = (
- [recyclerViewContext, recyclerViewManager]
- );
-
-+ // The ListHeaderComponent can resize on its own (async content settling inside it) without this component
-+ // re-rendering — firstItemOffset is only re-measured in this component's layout effects, so items would keep
-+ // stale on-screen positions and applyOffsetCorrection would never see the header delta. Trigger a layout pass
-+ // when the header's main-axis size changes. Inverted lists skip this: firstItemOffset is forced to 0 there,
-+ // so a header resize cannot shift item positions.
-+ const lastHeaderSizeRef = useRef(-1);
-+ const onHeaderLayout = useCallback(
-+ (event: LayoutChangeEvent) => {
-+ if (inverted) {
-+ return;
-+ }
-+ const headerSize = horizontal
-+ ? event.nativeEvent.layout.width
-+ : event.nativeEvent.layout.height;
-+ if (
-+ lastHeaderSizeRef.current >= 0 &&
-+ areDimensionsNotEqual(lastHeaderSizeRef.current, headerSize)
-+ ) {
-+ recyclerViewContext.layout();
-+ }
-+ lastHeaderSizeRef.current = headerSize;
-+ },
-+ [horizontal, inverted, recyclerViewContext]
-+ );
-+
- // Get secondary props and components
- const {
- refreshControl,
-@@ -509,7 +535,7 @@ const RecyclerViewComponent = (
- renderEmpty,
- CompatScrollView,
- renderStickyHeaderBackdrop,
-- } = useSecondaryProps(props);
-+ } = useSecondaryProps(props, onHeaderLayout);
-
- if (
- !recyclerViewManager.getIsFirstLayoutComplete() &&
-diff --git a/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx b/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx
-index 3012391..7375752 100644
---- a/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx
-+++ b/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx
-@@ -57,6 +57,10 @@ export function useRecyclerViewController(
- // Track the first visible item for maintaining scroll position
- const firstVisibleItemKey = useRef(undefined);
- const firstVisibleItemLayout = useRef(undefined);
-+ // firstItemOffset (the ListHeaderComponent's size) at the time the anchor above was captured. Item layouts are
-+ // header-relative, so a header resize shifts every item on screen without changing any tracked x/y — the delta
-+ // must be captured separately or offset correction is blind to it.
-+ const firstVisibleItemFirstItemOffset = useRef(0);
-
- // Queue to store callbacks that should be executed after scroll offset updates
- const pendingScrollCallbacks = useRef<(() => void)[]>([]);
-@@ -96,6 +100,18 @@ export function useRecyclerViewController(
- recyclerViewManager.getDataLength() > 0 &&
- recyclerViewManager.shouldMaintainVisibleContentPosition()
- ) {
-+ // When the viewport top sits inside the ListHeaderComponent, the header — not any data item — is the
-+ // user's visual anchor. The Math.max(0, startIndex) clamp below would otherwise anchor item 0 (which can
-+ // be far below the fold) and "maintain" its position through header resizes or data prepends, yanking
-+ // the viewport away from what the user is looking at. The header's own top never moves, so the correct
-+ // correction while it is the anchor is none — drop the tracked item instead.
-+ if (
-+ recyclerViewManager.getAbsoluteLastScrollOffset() <
-+ recyclerViewManager.firstItemOffset
-+ ) {
-+ firstVisibleItemKey.current = undefined;
-+ return;
-+ }
- // Update the tracked first visible item
- const firstVisibleIndex = Math.max(
- 0,
-@@ -107,6 +123,8 @@ export function useRecyclerViewController(
- firstVisibleItemLayout.current = {
- ...recyclerViewManager.getLayout(firstVisibleIndex),
- };
-+ firstVisibleItemFirstItemOffset.current =
-+ recyclerViewManager.firstItemOffset;
- }
- }
- }, [recyclerViewManager]);
-@@ -156,15 +174,23 @@ export function useRecyclerViewController(
- currentIndexOfFirstVisibleItem !== undefined &&
- currentIndexOfFirstVisibleItem >= 0
- ) {
-- // Calculate the difference in position and apply the offset
-- const diff = horizontal
-+ // Calculate the difference in position and apply the offset. Item layouts are header-relative,
-+ // so a ListHeaderComponent resize shifts every item on screen by the same amount without
-+ // changing any layout — it is only observable as a firstItemOffset delta, tracked separately.
-+ const layoutDiff = horizontal
- ? recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem).x -
- firstVisibleItemLayout.current!.x
- : recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem).y -
- firstVisibleItemLayout.current!.y;
-+ const diff =
-+ layoutDiff +
-+ (recyclerViewManager.firstItemOffset -
-+ firstVisibleItemFirstItemOffset.current);
- firstVisibleItemLayout.current = {
- ...recyclerViewManager.getLayout(currentIndexOfFirstVisibleItem),
- };
-+ firstVisibleItemFirstItemOffset.current =
-+ recyclerViewManager.firstItemOffset;
- if (
- diff !== 0 &&
- !pauseOffsetCorrection.current &&
-@@ -175,20 +201,27 @@ export function useRecyclerViewController(
- // console.log("scrollBy", diff);
- scrollAnchorRef.current?.scrollBy(diff);
- } else {
-+ // getAbsoluteLastScrollOffset() already reflects the current firstItemOffset (the
-+ // tracker's relative offset only resyncs on scroll events), so the header delta is
-+ // already embedded in it — add only the layout diff on scrollTo paths.
- const scrollToParams = horizontal
- ? {
-- x: recyclerViewManager.getAbsoluteLastScrollOffset() + diff,
-+ x:
-+ recyclerViewManager.getAbsoluteLastScrollOffset() +
-+ layoutDiff,
- animated: false,
- }
- : {
-- y: recyclerViewManager.getAbsoluteLastScrollOffset() + diff,
-+ y:
-+ recyclerViewManager.getAbsoluteLastScrollOffset() +
-+ layoutDiff,
- animated: false,
- };
- scrollViewRef.current?.scrollTo(scrollToParams);
- }
- if (hasDataChanged) {
- updateScrollOffsetWithCallback(
-- recyclerViewManager.getAbsoluteLastScrollOffset() + diff,
-+ recyclerViewManager.getAbsoluteLastScrollOffset() + layoutDiff,
- () => {}
- );
- recyclerViewManager.ignoreScrollEvents = true;
-diff --git a/node_modules/@shopify/flash-list/src/recyclerview/hooks/useSecondaryProps.tsx b/node_modules/@shopify/flash-list/src/recyclerview/hooks/useSecondaryProps.tsx
-index a64742c..7be3eb2 100644
---- a/node_modules/@shopify/flash-list/src/recyclerview/hooks/useSecondaryProps.tsx
-+++ b/node_modules/@shopify/flash-list/src/recyclerview/hooks/useSecondaryProps.tsx
-@@ -1,4 +1,4 @@
--import { Animated, RefreshControl } from "react-native";
-+import { Animated, LayoutChangeEvent, RefreshControl } from "react-native";
- import React, { useMemo } from "react";
-
- import { RecyclerViewProps } from "../RecyclerViewProps";
-@@ -24,7 +24,10 @@ import { getInvertedTransformStyle } from "../utils/getInvertedTransformStyle";
- * - renderStickyHeaderBackdrop: The sticky header backdrop component renderer
- * - CompatScrollView: The animated scroll component
- */
--export function useSecondaryProps(props: RecyclerViewProps) {
-+export function useSecondaryProps(
-+ props: RecyclerViewProps,
-+ onHeaderLayout?: (event: LayoutChangeEvent) => void
-+) {
- const {
- ListHeaderComponent,
- ListHeaderComponentStyle,
-@@ -73,11 +76,19 @@ export function useSecondaryProps(props: RecyclerViewProps) {
- return null;
- }
- return (
--
-+
- {getValidComponent(ListHeaderComponent)}
-
- );
-- }, [ListHeaderComponent, ListHeaderComponentStyle, invertedTransformStyle]);
-+ }, [
-+ ListHeaderComponent,
-+ ListHeaderComponentStyle,
-+ invertedTransformStyle,
-+ onHeaderLayout,
-+ ]);
-
- /**
- * Creates the footer component with optional styling.
diff --git a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+016+ignore-stale-viewholder-render-layout.patch b/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+016+ignore-stale-viewholder-render-layout.patch
deleted file mode 100644
index 572d6b9790bf..000000000000
--- a/patches/@shopify/flash-list/@shopify+flash-list+2.3.0+016+ignore-stale-viewholder-render-layout.patch
+++ /dev/null
@@ -1,97 +0,0 @@
-diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
---- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
-+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
-@@ -467,7 +467,7 @@ function RecyclerView(props) {
- isHorizontalRTL && viewToMeasureBoundedSize,
- renderHeader,
- !isHorizontalRTL && viewToMeasureBoundedSize,
-- React.createElement(ViewHolderCollection, { viewHolderCollectionRef: viewHolderCollectionRef, data: data, horizontal: horizontal, renderStack: recyclerViewManager.getRenderStack(), getLayout: (index) => recyclerViewManager.getLayout(index), getAdjustmentMargin: () => {
-+ React.createElement(ViewHolderCollection, { viewHolderCollectionRef: viewHolderCollectionRef, data: data, horizontal: horizontal, renderStack: recyclerViewManager.getRenderStack(), getLayout: (index) => recyclerViewManager.tryGetLayout(index), getAdjustmentMargin: () => {
- if (!shouldRenderFromBottom || !recyclerViewManager.hasLayout()) {
- return 0;
- }
-diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts
---- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts
-+++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.d.ts
-@@ -19,7 +19,7 @@ export interface ViewHolderCollectionProps {
- index: number;
- }>;
- /** Function to get layout information for a specific index */
-- getLayout: (index: number) => RVLayout;
-+ getLayout: (index: number) => RVLayout | undefined;
- /** Ref to control layout updates from parent components */
- viewHolderCollectionRef: React.Ref;
- /** Map to store refs for each ViewHolder instance */
-diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js
---- a/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js
-+++ b/node_modules/@shopify/flash-list/dist/recyclerview/ViewHolderCollection.js
-@@ -290,6 +290,13 @@ export function ViewHolderCollection(props) {
- return (React.createElement(CompatView, { ref: containerRef, style: hasData && containerStyle }, containerLayout &&
- hasData &&
- renderEntriesRef.current.map(([reactKey, { index }]) => {
-+ const layout = getLayout(index);
-+ // The render stack can retain an entry whose index points past the
-+ // end of the current layouts array when the data length shrinks
-+ // mid-render. Skip it instead of throwing indexOutOfBounds.
-+ if (layout === undefined) {
-+ return null;
-+ }
- const item = data[index];
- // Suppress separators for items in the last row to prevent
- // height mismatch. The last data item has no separator (no
-@@ -298,7 +305,7 @@ export function ViewHolderCollection(props) {
- ? data[index + 1]
- : undefined;
- return (React.createElement(ViewHolder, { key: reactKey, index: index, item: item, trailingItem: trailingItem, layout: {
-- ...getLayout(index),
-+ ...layout,
- }, refHolder: refHolder, onSizeChanged: onSizeChanged, target: "Cell", renderItem: renderItem, extraData: extraData, CellRendererComponent: CellRendererComponent, ItemSeparatorComponent: ItemSeparatorComponent, horizontal: horizontal, hidden: hideStickyHeaderRelatedCell && currentStickyIndex === index, inverted: inverted }));
- })));
- };
-diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx
---- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx
-+++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx
-@@ -705,7 +705,7 @@ function RecyclerView(props: RecyclerViewProps) {
- data={data}
- horizontal={horizontal}
- renderStack={recyclerViewManager.getRenderStack()}
-- getLayout={(index) => recyclerViewManager.getLayout(index)}
-+ getLayout={(index) => recyclerViewManager.tryGetLayout(index)}
- getAdjustmentMargin={() => {
- if (!shouldRenderFromBottom || !recyclerViewManager.hasLayout()) {
- return 0;
-diff --git a/node_modules/@shopify/flash-list/src/recyclerview/ViewHolderCollection.tsx b/node_modules/@shopify/flash-list/src/recyclerview/ViewHolderCollection.tsx
---- a/node_modules/@shopify/flash-list/src/recyclerview/ViewHolderCollection.tsx
-+++ b/node_modules/@shopify/flash-list/src/recyclerview/ViewHolderCollection.tsx
-@@ -23,7 +23,7 @@ export interface ViewHolderCollectionProps {
- /** Map of indices to React keys for each rendered item */
- renderStack: Map;
- /** Function to get layout information for a specific index */
-- getLayout: (index: number) => RVLayout;
-+ getLayout: (index: number) => RVLayout | undefined;
- /** Ref to control layout updates from parent components */
- viewHolderCollectionRef: React.Ref;
- /** Map to store refs for each ViewHolder instance */
-@@ -176,6 +176,13 @@ export function ViewHolderCollection(props: ViewHolderCollectionProps) {
- {containerLayout &&
- hasData &&
- Array.from(renderStack.entries(), ([reactKey, { index }]) => {
-+ const layout = getLayout(index);
-+ // The render stack can retain an entry whose index points past the
-+ // end of the current layouts array when the data length shrinks
-+ // mid-render. Skip it instead of throwing indexOutOfBounds.
-+ if (layout === undefined) {
-+ return null;
-+ }
- const item = data[index];
- // Suppress separators for items in the last row to prevent
- // height mismatch. The last data item has no separator (no
-@@ -192,7 +199,7 @@ export function ViewHolderCollection(props: ViewHolderCollectionProps) {
- item={item}
- trailingItem={trailingItem}
- layout={{
-- ...getLayout(index),
-+ ...layout,
- }}
- refHolder={refHolder}
- onSizeChanged={onSizeChanged}
diff --git a/patches/@shopify/flash-list/details.md b/patches/@shopify/flash-list/details.md
deleted file mode 100644
index c2a5178df699..000000000000
--- a/patches/@shopify/flash-list/details.md
+++ /dev/null
@@ -1,203 +0,0 @@
-# `@shopify/flash-list` patches
-
-### [@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch](@shopify+flash-list+2.3.0+001+fix-horizontal-height-normalization.patch)
-
-- Reason: Fixes height normalization in horizontal FlashList when items change. `LinearLayoutManager.normalizeLayoutHeights` had three issues:
- 1. **Screen resize / item shrink**: When items shrink, `tallestItemHeight` was updated prematurely, causing the next cycle to skip re-normalization. Fixed by resetting tallest item tracking when `targetMinHeight === 0` so the next repaint re-detects the tallest item.
- 2. **Tallest item removed**: When the tallest item is deleted from the list, all remaining items kept the old `minHeight` forever because no item could pass the `height > minHeight` check. Fixed by detecting when `tallestItem` is no longer in `this.layouts` and resetting tracking with a repaint.
- 3. **New smaller item added**: When the tallest item is already tracked, newly added items never got `minHeight` applied because there was no code path to normalize them. Fixed by applying `minHeight`/`height` to any unnormalized items when a tallest item is already tracked.
-- Upstream PR/issue: https://github.com/Shopify/flash-list/pull/2096
-- E/App issue: https://github.com/Expensify/App/issues/33725
-- PR introducing patch: https://github.com/Expensify/App/pull/81566
-
-### [@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch](@shopify+flash-list+2.3.0+002+skip-layout-when-hidden.patch)
-
-- Reason: Prevents FlashList from losing its render state when a navigation stack hides the parent container with `display: none`. Four guards in total — two in `RecyclerView` to skip layout processing while hidden, and two in `useRecyclerViewController` to make scroll methods safe while hidden:
- 1. **First `useLayoutEffect`** in `RecyclerView` (measures parent container): After calling `measureParentSize()`, if both width and height are 0, return early before calling `updateLayoutParams()` or updating `containerViewSizeRef`. This preserves the last known valid window size and prevents the layout manager from receiving zero dimensions.
- 2. **Second `useLayoutEffect`** in `RecyclerView` (measures individual items): If `containerViewSizeRef.current` is 0x0 (because the first effect bailed out), return early before calling `modifyChildrenLayout()`. This prevents item measurements taken under `display: none` (also 0) from corrupting stored layouts.
- 3. **`scrollToIndex`** in `useRecyclerViewController`: When the list is hidden, guards 1/2 leave `layoutManager` undefined. Any `scrollToIndex` call (also reached via `scrollToEnd`, `scrollToItem`, `scrollToTop`) would then throw "LayoutManager is not initialized, window size is unavailable" from `recyclerViewManager.getWindowSize()`. Early-return a resolved Promise when `!recyclerViewManager.hasLayout()` so the call becomes a safe no-op; the list will scroll correctly on its next layout pass.
- 4. **`scrollToOffset` RTL+horizontal branch** in `useRecyclerViewController`: Only the `I18nManager.isRTL && horizontal` branch reads `getChildContainerDimensions()` and `getWindowSize()`, both of which throw when `layoutManager` is undefined. Gate the branch on `recyclerViewManager.hasLayout()` so the RTL math is skipped while hidden; the non-RTL / vertical paths are unaffected and continue using the underlying `scrollViewRef.scrollTo()` directly.
- When the container becomes visible again, `onLayout` fires (React Native Web uses ResizeObserver), triggering a re-render with correct dimensions so FlashList resumes normally without re-initialization.
-- Files changed: `dist/recyclerview/RecyclerView.js` and `dist/recyclerview/hooks/useRecyclerViewController.js`.
-- Upstream PR/issue: TBD
-- E/App issue: https://github.com/Expensify/App/issues/83976 (original), https://github.com/Expensify/App/issues/90756 (scroll-while-hidden follow-up)
-- PR introducing patch: https://github.com/Expensify/App/pull/84887
-
-### [@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch](@shopify+flash-list+2.3.0+003+fix-inverted-scroll-direction-on-web.patch)
-
-- Reason: Fixes inverted scroll direction on web. FlashList uses `scaleY: -1` / `scaleX: -1` CSS transform to visually invert the list, but the browser's native wheel scroll doesn't flip accordingly — scrolling down visually scrolls up and vice versa. This patch adds a `useEffect` in `RecyclerView` that attaches a `wheel` event listener on web when `inverted` is true, intercepting the event, negating the scroll delta, and manually adjusting `scrollTop`/`scrollLeft`. Mirrors the same fix applied in react-native-web's `VirtualizedList`.
-- Upstream PR/issue: TBD
-- E/App issue: https://github.com/Expensify/App/issues/33725
-- PR introducing patch: https://github.com/Expensify/App/pull/85114
-
-### [@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch](@shopify+flash-list+2.3.0+004+fix-inverted-first-item-offset.patch)
-
-- Reason: Fixes inverted lists rendering only a few items with white space on scroll. FlashList's `RecyclerView` measures `firstItemOffset` by calling `measureFirstChildLayout` relative to the outer container. When `inverted` is true, the outer container has `scaleY: -1`, which flips the coordinate system — causing the measured y-offset to equal the container height instead of 0. This makes all scroll offsets negative after adjustment (`adjustedOffset = scrollOffset - firstItemOffset`), so the viewport thinks it's in negative space where no items exist. Only items caught by the draw-distance buffer render. The fix forces `firstItemOffset` to 0 for inverted lists, since the transform already handles visual inversion.
-- Upstream PR/issue: https://github.com/Shopify/flash-list/pull/2300
-- E/App issue: https://github.com/Expensify/App/issues/33725
-- PR introducing patch: https://github.com/Expensify/App/pull/85114
-
-### [@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch](@shopify+flash-list+2.3.0+005+fix-pending-children-blocking-measurements.patch)
-
-- Reason: Fixes items overlapping on initial load when a list contains nested FlashLists (e.g. a horizontal list inside a chat message). The `RecyclerView` layout measurement `useLayoutEffect` had an early return when `pendingChildIds.size > 0` — while any nested FlashList was still doing its progressive first layout, the parent list skipped ALL measurement processing. This meant newly added items stayed at estimated positions (wrong heights/y-offsets) while being visible (`opacity: 1`), causing overlap. The fix moves the `pendingChildIds` check so that measurements are always collected and processed by the layout manager, but when children are pending, `commitLayout()` is called instead of `setRenderId()`. This updates item positions in `ViewHolderCollection` without triggering a full `RecyclerView` re-render, avoiding the cascading `setState` calls that the original guard was meant to prevent.
-- Upstream PR/issue: TBD
-- E/App issue: https://github.com/Expensify/App/issues/33725
-- PR introducing patch: https://github.com/Expensify/App/pull/85114
-
-### [@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch](@shopify+flash-list+2.3.0+006+fix-inverted-mvcp-android.patch)
-
-- Reason: Fixes `maintainVisibleContentPosition` not working on Android for inverted lists when items are prepended (e.g. new messages arriving, or `useFlashListScrollKey` switching from sliced to full data). FlashList's offset correction uses a `ScrollAnchor` component — an invisible absolutely-positioned element whose `top` changes to trigger the native `maintainVisibleContentPosition` on the ScrollView. On Android, where inversion uses `rotate: 180deg` (vs `scaleY: -1` on iOS), this mechanism silently fails: the anchor position changes but the native ScrollView does not adjust its scroll offset. The fix detects the specific case (`inverted && Platform.OS === 'android' && hasDataChanged`) and bypasses `ScrollAnchor` in favor of a deferred `scrollTo` via `requestAnimationFrame`, which fires after the native layout has committed the new content size. Non-inverted lists, iOS, web, and layout-only corrections (no data change) are unaffected and continue using the original code paths.
-- Upstream PR/issue: TBD
-- E/App issue: https://github.com/Expensify/App/issues/33725
-- PR introducing patch: https://github.com/Expensify/App/pull/85114
-
-### [@shopify+flash-list+2.3.0+007+fix-scroll-anchor-unmount-on-ios.patch](@shopify+flash-list+2.3.0+007+fix-scroll-anchor-unmount-on-ios.patch)
-
-- Reason: Fixes a scroll position reset on iOS when `maintainVisibleContentPosition.disabled` toggles from `true` to `false` (e.g. when `shouldMaintainVisibleContentPosition` changes based on scroll offset). Root cause: `ScrollAnchor` was conditionally rendered based on `shouldMaintainVisibleContentPosition()`. When MVCP was disabled, the anchor unmounted, which made the native Fabric `_firstVisibleView` weak-ref become nil. When MVCP was re-enabled, the anchor remounted at `top: 1,000,000` (its initial position), but `_prevFirstVisibleFrame` was stale at `1,000,000 + X` from the prior anchor instance. `_adjustForMaintainVisibleContentPosition` then computed `deltaY = 0 - (1,000,000 + X)` — a massive negative offset — causing the list to jump to the start. The fix decouples anchor lifetime from the `disabled` flag: `ScrollAnchor` is now always mounted (and `maintainVisibleContentPositionInternal` always non-null) whenever `maintainVisibleContentPosition` prop is defined. The `disabled` flag continues to gate JS-level `scrollBy` corrections in `applyOffsetCorrection` (via `shouldMaintainVisibleContentPosition()`), so the anchor stays in place when MVCP is logically off — the native side always has a live `_firstVisibleView` and a fresh `_prevFirstVisibleFrame` to diff against.
-- Files changed: Both `src/recyclerview/RecyclerView.tsx` and `dist/recyclerview/RecyclerView.js`.
-- Upstream PR/issue: TBD
-- E/App issue: https://github.com/Expensify/App/issues/33725
-- PR introducing patch: https://github.com/Expensify/App/pull/88923
-
-### [@shopify+flash-list+2.3.0+008+increase-timeout.patch](@shopify+flash-list+2.3.0+008+increase-timeout.patch)
-
-- Reason: Fixes an initial-render scroll jump on iOS for inverted lists using `initialScrollIndex`. The existing 100 ms `pauseOffsetCorrection` window in `applyInitialScrollIndex` wasn't long enough — MVCP resumed before the corrective `scrollToOffset` had settled, exposing the jump. Bumped to 500 ms.
-- Files changed: `dist/recyclerview/hooks/useRecyclerViewController.js` only.
-- Upstream PR/issue: TBD
-- E/App issue: https://github.com/Expensify/App/issues/89768
-- PR introducing patch: https://github.com/Expensify/App/pull/90218
-
-### [@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch](@shopify+flash-list+2.3.0+009+ignore-stale-viewholder-layout.patch)
-
-- Reason: Prevents stale `ViewHolder.onLayout` callbacks from crashing FlashList after the list data/layout table has changed. `validateItemSize` previously read the stored layout with `recyclerViewManager.getLayout(index)`, which throws when the callback's render-time index is no longer present in the layout manager. The patch uses `recyclerViewManager.tryGetLayout(index)` and returns early when the layout is missing, so obsolete measurements are ignored while current indexes continue through the existing width/height comparison.
-- Files changed: Both `src/recyclerview/RecyclerView.tsx` and `dist/recyclerview/RecyclerView.js`.
-- Upstream PR/issue: https://github.com/Shopify/flash-list/issues/2291
-- E/App issue: https://github.com/Expensify/App/issues/89933
-- PR introducing patch: https://github.com/Expensify/App/pull/91248
-
-### [@shopify+flash-list+2.3.0+010+fix-web-subpixel-rounding.patch](@shopify+flash-list+2.3.0+010+fix-web-subpixel-rounding.patch)
-
-- Reason: Fixes a "Maximum update depth exceeded" infinite render loop on web (mostly Windows with fractional display scaling). `roundOffPixel` on web was a no-op, so subpixel drift in the child container's `getBoundingClientRect()` width re-triggered `ViewHolderCollection`'s `[fixedContainerSize]` layout effect on every measurement. The patch implements `roundOffPixel` to snap to the device-pixel grid (`Math.round(value * devicePixelRatio) / devicePixelRatio`), matching native `PixelRatio.roundToNearestPixel`. Two measurements that paint the same physical pixel now collapse to the same JS value, breaking the loop.
-- Files changed: `dist/recyclerview/utils/measureLayout.web.js` only.
-- Upstream PR/issue: TBD
-- E/App issue: https://github.com/Expensify/App/issues/91584
-- Sentry: https://expensify.sentry.io/issues/APP-DQ2
-- PR introducing patch: https://github.com/Expensify/App/pull/91799
-
-### [@shopify+flash-list+2.3.0+011+sort-for-natural-DOM-order.patch](@shopify+flash-list+2.3.0+011+sort-for-natural-DOM-order.patch)
-
-- Reason: Fixes scrambled DOM order in virtualized list items on web. FlashList uses `position: absolute` to position items, so visual order is determined by CSS `top`/`left` values rather than DOM order. Due to recycling (reusing ViewHolder components for different data items), the DOM order reflects Map insertion order rather than data index order. This causes three web-specific issues:
-
- 1. **Screen reader reading order**: Assistive technologies follow DOM order, so items are read in a scrambled sequence that doesn't match the visual layout.
- 2. **Keyboard Tab navigation**: Tab key follows DOM order, so focus jumps unpredictably between items instead of following the visual top-to-bottom sequence.
- 3. **Cross-item text selection**: Selecting text across multiple list items selects them in DOM order rather than visual order, producing garbled selections.
-
- **How it works:**
-
- 1. **Stable render order during scroll**: Render entries are maintained in a ref (`renderEntriesRef`) that preserves its order across renders. On each render, a reconcile step removes keys that left the render stack and appends new keys. Because FlashList's recycling mutates index values in place on shared object references (`keyInfo.index = newIndex`), the entries in the ref always have current index values without needing updates — only the array order can be stale. This means during normal scrolling, React sees children in the same order and produces zero `insertBefore` calls, avoiding any DOM reordering.
-
- 2. **Deferred sort after scroll** (default `SORT_DELAY_MS` = 1000ms): After scrolling pauses, a single-slot `setTimeout` (armed by `schedulePendingSort`, with the handle held inside `useDeferredCallback`) sorts the ref by data index and triggers a re-render. This is the only moment React reorders DOM nodes via `insertBefore`. The delay gives the browser time to process queued pointer events (hover state cleanup) from CSS position changes before the structural DOM reorder occurs. When the timer fires, it re-checks scroll state via `isScrolling()` — if any scroll is still in progress (a freshly started mousewheel, a continued momentum scroll, etc.), the timer reschedules itself rather than committing, so a long-running scroll never lets a stale timer fire in the middle of motion. The sort uses a separate, sort-only re-render trigger (`bumpSortVersion` from a `useReducer` counter) instead of reusing FlashList's `renderId`, so the sort does not fire lifecycle callbacks (`onCommitLayoutEffect`, `onCommitEffect`) that would cause duplicate `onViewableItemsChanged` or `onEndReached` calls.
-
- 3. **Focus-aware sort triggering**: Tab navigation walks DOM order on web, so an out-of-date order makes the next Tab press land on the wrong row. A `focusin` event listener on the container resolves which logical row received focus by reading a `data-flashlist-index` DOM marker that each `ViewHolder` renders alongside its children, and routes real focus changes to `maybeDoSortOnFocus`. Spurious refocus events caused by recycling and React's mutation-phase selection-preservation are filtered out so they don't trigger a sort cascade — see [viewholder-marker-and-focus-filter.md](viewholder-marker-and-focus-filter.md) for the full filter design. Tab itself doesn't scroll, but tabbing to a row that's outside the viewport makes the browser auto-scroll to bring it into view; that scroll re-renders the list and runs a separate `maybeDoSortOnScroll` callback. The actual synchronous sort during Tab navigation happens in the scroll callback (see #4); the focus callback typically just schedules a deferred sort.
-
- 4. **Two `maybeDoSort` callbacks + programmatic-scroll gating**: The focus path and the scroll path have different decisions to make, so the original single `maybeDoSort` is split into two callbacks that cooperate via a one-shot flag (`shouldSortOnNextFocusRef`):
-
- - **`maybeDoSortOnScroll`** runs from the effect that fires on `renderStack` / `renderId` changes — i.e. whenever recycling produced a new layout. It arms `shouldSortOnNextFocusRef`, evicts any pending-sort timer (a stale timer from a previous scroll's drain cannot fire mid-motion during rapid arrow-key repeats), then picks one of three branches:
- - *Programmatic scroll queued or in flight* (`isScrollingProgrammatically()` is true): hand off via `runAfterProgrammaticScroll` → `schedulePendingSort`. Once the scroll settles we still wait an additional `SORT_DELAY_MS` for queued pointer/focus events to land before committing. The flag stays armed.
- - *In-motion scroll caused by a recent focus* (`isScrolling()` is true and the last `scroll` event landed within `FOCUS_INDUCED_SCROLL_WINDOW_MS` = 30 ms after the last `focusin`): call `sortItems` synchronously and reset the flag. This is the browser's auto-scroll-into-view from a Tab/focus on an off-viewport row — keeping DOM order synced is critical for the next Tab to land on the right row, even at the cost of perturbing the auto-scroll. **This is the path that does the sync sort during Tab navigation.**
- - *Anything else* (user mousewheel/scrollbar/touch, or a quiet list): schedule the deferred sort. The flag stays armed for the next focusin to consume.
-
- - **`maybeDoSortOnFocus`** runs from the `focusin` listener. It evicts any pending-sort timer; if `shouldSortOnNextFocusRef` is armed it consumes the flag and commits `sortItems` synchronously; either way it then schedules a fresh deferred sort. In the common Tab → auto-scroll flow, `maybeDoSortOnScroll`'s focus-induced branch has already done the sync sort and reset the flag *before* the next focusin gets here, so the sync-sort path inside this callback is mainly a safety net for scroll-less re-renders and for the programmatic-scroll branch (where the flag was armed but no sync sort fired).
-
- The deferred-sort timer is provided by `useDeferredCallback`, a small inline hook that wraps a single-slot `setTimeout` with a fire-time `shouldDefer` predicate. When the timer expires it re-checks `isScrolling()` and reschedules itself if a scroll is still in progress, so a long-running scroll never lets a stale timer fire in the middle of motion. The "scroll has truly ended" signal driving the programmatic-defer drain is FlashList's existing `isMomentumEnd`, fired by `VelocityTracker` ~100 ms after the last `scroll` event — distance-independent and naturally overlap-safe (the browser merges overlapping smooth scrolls into one).
-
- 5. **Pre-scroll announcement (`announceProgrammaticScroll`)**: A new public method on `FlashListRef` lets the consumer announce an imminent programmatic scroll *before* `scrollToIndex` is actually called. It flips an "is queued" ref that `isScrollingProgrammatically()` already ORs in, so any sort triggered by an intervening event (notably the `focusin` that fires when the consumer focuses the target row first and only then calls `scrollToIndex`) is correctly held off rather than committing immediately and cancelling the upcoming smooth scroll. The queued flag is handed off to the in-flight ref at `scrollToIndex` entry and finally cleared when the scroll settles, so it cannot get stuck on.
-
- **Why the deferred approach is necessary:**
-
- Two distinct web-only hazards make immediate, mid-scroll DOM reordering wrong:
-
- 1. **Hover/pointer state loss**: When recycling moves items to new CSS positions, the browser queues `mouseleave`/`pointerleave` events for elements that are no longer under the pointer. However, if `insertBefore` executes before the browser has processed those queued pointer events, the structural DOM move interferes with the browser's hover tracking — the pending `mouseleave` is effectively lost, and recycled items retain stale hover/tooltip states. Keeping the array order stable during scrolling and only committing after the list goes idle gives the browser time to drain those events before any reorder.
-
- 2. **Smooth-scroll cancellation**: When a list row is focused and a sort commit lands during an in-flight smooth `scrollToIndex`, React's commit-time selection-preservation logic saves and writes back `scrollTop` on every scrollable ancestor of the focused element (including the FlashList scroll container). Per CSSOM, writing `scrollTop` performs an instant scroll, which aborts any in-flight `behavior: 'smooth'` animation on that element — the visible "scroll starts then freezes" symptom on long arrow-key navigations. The programmatic-scroll gating in both `maybeDoSort*` callbacks keeps commits out of the smooth-scroll window, so a `scrollToIndex` animation lands only after it has truly ended (`isMomentumEnd`). Browser auto-scroll-into-view triggered by Tab focusing an off-viewport row is intentionally *not* gated this way (see #4 above) — Tab-navigation correctness takes priority over preserving that auto-scroll's centring.
-
- **Platform gating:**
-
- On web: render entries are held in the order-preserving ref, the deferred sort fires after scrolling pauses, the `focusin` listener (filtered via the `data-flashlist-index` marker) routes real focus changes through `maybeDoSortOnFocus`, and `maybeDoSortOnScroll` decides per-render whether to sort synchronously, defer until momentum-end, or defer the standard `SORT_DELAY_MS`. The deferred path itself reschedules until any scroll has settled, via `useDeferredCallback`'s timer-fire `isScrolling()` re-check.
- On non-web: the ref is set to a fresh `Array.from(renderStack.entries())` on every render, preserving original behavior identically. The marker JSX, the focusin listener, and both `maybeDoSort*` callbacks are gated to web only.
-
-- Upstream PR/issue: https://github.com/Shopify/flash-list/issues/1955
-- E/App issue: https://github.com/Expensify/App/issues/86126
-- PR introducing patch: https://github.com/Expensify/App/pull/85825
-
-### [@shopify+flash-list+2.3.0+012+fix-scrollbar-oscillation-crash.patch](@shopify+flash-list+2.3.0+012+fix-scrollbar-oscillation-crash.patch)
-
-- Reason: Fixes a "Maximum update depth exceeded" (#185) infinite render loop on web with classic (non-overlay) scrollbars — i.e. Windows/Linux Chrome and macOS with "Always show scroll bars".
-
- A vertical list gets its width from `firstChildViewLayout.width`, the scroll viewport's **client** width, which leaves out the scrollbar. So every time the scrollbar shows or hides, the width changes by about 15px. That relayouts, which changes the content height, which toggles the scrollbar again, and it never settles.
-
- This only happens on lists that get **shorter as they get narrower**. If narrowing made them taller, the scrollbar would stay put after one toggle and the layout would settle on its own. That matters for how the lock is released below.
-
- `LinearLayoutManager.updateLayoutParams` sends the measured size through `settleScrollbarOscillation` (web only, since native scrollbars overlay the content). We only call it a flicker when all of these are true, so a real resize is never mistaken for one:
-
- 1. **Two distinct values** in the last 8 rounded sizes. A drag passes through many widths, and rounding absorbs subpixel drift.
- 2. **At least 3 flips** between them. One toggle only flips twice. We keep 8 samples rather than 4 because the toggle can lag a frame, so the bounce is often `A,A,B,B`.
- 3. **A scrollbar-sized gap**, at most 25px. Classic scrollbars are ~15-17px.
- 4. **The current frame is on the smaller value**, so we lock to the width that's actually on screen.
-
- Then we lock `boundedSize` to the smaller value, which already leaves room for the scrollbar, so rows never overflow.
-
- **Releasing.** Once we lock, the scrollbar disappears and the next frame measures the wider width again. Releasing as soon as we see it doesn't end the loop, it just makes each round slower. So:
-
- - The **smaller** value keeps the lock.
- - The **larger** value gives the real width back `MAX_LOCK_RELEASE_CYCLES` (1) time, then the lock holds. The counter isn't reset when the same pair locks again, or a flicker would top it up forever.
- - Anything **outside the pair** is a real resize, so release and reset.
-
- With the lock held, `boundedSize` stops changing, `recomputeLayouts` stops running and the re-renders stop. The trade-off: the flicker usually uses up the one allowed release, so the width stays put until the next real resize. A list that later stops needing a scrollbar keeps about 15px of empty space on the right.
-- Files changed: `dist/recyclerview/layout-managers/LinearLayoutManager.js` only.
-- Upstream PR/issue: https://github.com/Shopify/flash-list/issues/2334
-- E/App issue: https://github.com/Expensify/App/issues/91584, https://github.com/Expensify/App/issues/92263, https://github.com/Expensify/App/issues/95719
-- PR introducing patch: https://github.com/Expensify/App/pull/92520 (hardened for #95719)
-
-### [@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch](@shopify+flash-list+2.3.0+013+improve-scroll-key-handling.patch)
-
-- Reason: Adds `viewPosition` support to `initialScrollIndexParams` (0 = start, 0.5 = center, 1 = end — same semantics as `scrollToIndex`'s `viewPosition`). Six changes:
- 1. **`applyInitialScrollIndex`** in `useRecyclerViewController.js`: the corrective scroll for `initialScrollIndex` now shifts the target offset by `(containerSize - itemSize) * viewPosition` (clamped to ≥ 0, and skipped while the container is unmeasured), mirroring `scrollToIndex`'s math.
- 2. **`applyInitialScrollAdjustment`** in `RecyclerViewManager.js`: the initial render window is anchored with the same `viewPosition` adjustment, so the very first painted frame already renders the items around the centered position — without this, the first frame renders items from the target's raw offset (target at the viewport edge) and visibly jumps once the first corrective scroll lands.
- 3. **Bottom crop** in `applyInitialScrollIndex` (`useRecyclerViewController.js`): for inverted vertical lists positioned via `viewPosition`, when the bottom-most visible item is flush against the bottom edge and another item exists underneath it, the offset is nudged up so the current bottom item is cropped by a few pixels — signaling there is more content below.
- 4. **`recomputeLayouts` range** in `applyInitialScrollAdjustment` (`RecyclerViewManager.js`): the recompute that precedes reading the target offset is widened from `recomputeLayouts(0, initialScrollIndex)` to `recomputeLayouts(0, this.getDataLength() - 1)`, so every item gets a measured/re-estimated layout before the positioning.
- 5. **Deferred re-scroll reads the latest offset** in `applyInitialScrollIndex` (`useRecyclerViewController.js`): the `setTimeout(0)` re-scroll used to close over the `offset` from its own commit. When a later commit recomputed a newer offset before that timeout fired, the stale timeout snapped the list back to the outdated offset — a visible jump. The offset is now stored in `latestInitialScrollOffsetRef` and read at fire-time, so any pending re-scroll targets the current offset instead of a stale one.
- 6. **Progressive render covers the drawDistance buffer** in `renderProgressively` (`RecyclerViewManager.js`): with an explicit `initialScrollIndex`, the drawDistance buffer used to mount right after first paint; its measurements re-estimated every still-unmeasured item before the target, which could collapse the content height below the applied scroll offset and make the native ScrollView clamp. Now the progressive-render phase also waits for the buffer around the viewport to be measured, so the layout converges before anything is painted. Only applies when `initialScrollIndex` is set; other lists keep stock behavior.
-- Files changed: `dist/FlashListProps.d.ts`, `dist/recyclerview/hooks/useRecyclerViewController.js`, `dist/recyclerview/RecyclerViewManager.js`.
-- Upstream PR/issue: https://github.com/Shopify/flash-list/pull/2318 (for point 4)
-- E/App issue: https://github.com/Expensify/App/issues/92152
-- PR introducing patch: https://github.com/Expensify/App/pull/93403
-
-### [@shopify+flash-list+2.3.0+014+external-window-size.patch](@shopify+flash-list+2.3.0+014+external-window-size.patch)
-
-- Reason: Adds an **`overrideWindowSize`** prop that lets a list declare its visible window (`{width, height}`) instead of deriving it from `measureParentSize(internalViewRef)`. Needed for an *externally-driven* list — one whose `renderScrollComponent` is a non-scrolling `View` that grows to the full content height and receives synthetic scroll events from a parent scroller. Without this, FlashList measures the outer container (as tall as all content) as its viewport and renders every row, defeating virtualization. The change is minimal: `measureParentSize` is still assigned to `outerViewSize` and used for `containerViewSizeRef` (layout-change detection) and the 0×0 hidden-guard from patch 002; only the `windowSize` fed to `updateLayoutParams` is `overrideWindowSize ?? outerViewSize`. Fully backward compatible — when the prop is unset, `windowSize === outerViewSize` and behavior is byte-identical. Used by `MoneyRequestReportView`'s horizontally-scrollable transaction table (`ExternalScrollFlashListTable`), which windows its rows against the unified list's vertical scroll.
-- Files changed: `src/FlashListProps.ts`, `src/recyclerview/RecyclerView.tsx`, `dist/FlashListProps.d.ts`, `dist/recyclerview/RecyclerView.js`.
-- Upstream PR/issue: TBD
-- E/App issue: https://github.com/Expensify/App/issues/91425
-- PR introducing patch: https://github.com/Expensify/App/pull/91422
-
-### [@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch](@shopify+flash-list+2.3.0+015+mvcp-header-aware.patch)
-
-- Reason: Makes `maintainVisibleContentPosition` aware of the `ListHeaderComponent`. Item layouts are header-relative, so MVCP was blind to the header in two symmetric ways:
- 1. **Header resize was never corrected**: when the header changes height after layout (e.g. a nested virtualized table settling from estimated to measured row heights, ~400px on a 207-row table), every data item shifts on screen but no tracked `x`/`y` changes — the anchored item (e.g. a deep-linked report action positioned via `initialScrollIndex`) drifts out of the viewport with no correction. Fixed by capturing `firstItemOffset` alongside the anchor layout (`firstVisibleItemFirstItemOffset`) and including its delta in the correction diff. On the `ScrollAnchor.scrollBy` path (iOS/Android) the full diff is applied; on the `scrollTo` fallback paths (web, Android inverted) only the layout diff is added, because `getAbsoluteLastScrollOffset()` already reflects the current `firstItemOffset` (the tracker's relative offset only resyncs on scroll events), so the header delta is already embedded in it.
- 2. **Wrong anchor while viewing the header**: `computeFirstVisibleIndexForOffsetCorrection` clamps the anchor to `Math.max(0, startIndex)`, so with the viewport over the header it tracked item 0 — possibly far below the fold — and "maintained" that off-screen item's position through data prepends/header growth, yanking the viewport away from the header (report opening at the top visibly jumped down to the chat). Fixed by dropping the anchor (`firstVisibleItemKey = undefined`) whenever the absolute scroll offset is smaller than `firstItemOffset`: the header is the user's visual anchor then, and its top never moves, so the correct correction is none.
- Additionally, `RecyclerView` now observes the header wrapper's `onLayout` (main-axis size only, skipped for inverted lists where `firstItemOffset` is forced to 0) and triggers `recyclerViewContext.layout()` on change. Without this, a header that resizes from a commit inside its own subtree (the nested table settling) never causes the parent list to re-measure `firstItemOffset`, so items keep stale positions and `applyOffsetCorrection` never sees the delta.
- Used by `MoneyRequestReportView`'s horizontally-scrollable transaction table, where the whole table is the unified list's `ListHeaderComponent`: deep links into the report actions below the table now stay anchored while the table settles, and MVCP could be re-enabled for that mode (the `{disabled: true}` workaround is removed).
-- Files changed: `src/recyclerview/RecyclerView.tsx`, `src/recyclerview/hooks/useRecyclerViewController.tsx`, `src/recyclerview/hooks/useSecondaryProps.tsx`, and their `dist` counterparts.
-- Upstream PR/issue: TBD
-- E/App issue: https://github.com/Expensify/App/issues/91425
-- PR introducing patch: https://github.com/Expensify/App/pull/91422
-
-### [@shopify+flash-list+2.3.0+016+ignore-stale-viewholder-render-layout.patch](@shopify+flash-list+2.3.0+016+ignore-stale-viewholder-render-layout.patch)
-
-- Reason: Prevents an `index out of bounds, not enough layouts` crash thrown while `ViewHolderCollection` renders. This is the render-path sibling of patch `009`, which only guarded the `validateItemSize` measurement callback. The crash originates in upstream flash-list and reproduces on **every platform** (native crash: `APP-8PG`), not just web. The render stack (`RenderStackManager.keyMap`, returned by `RecyclerViewManager.getRenderStack()`) can hold an entry whose stored `index` exceeds the current `layouts` length when the list `data` shrinks between renders (e.g. deleting a report action, IOU actions being filtered once transactions load, or a Concierge draft being removed). This is a timing gap inside flash-list's own update pipeline: on a data shrink `LayoutManager.modifyLayout` truncates `this.layouts` synchronously (`getLayoutCount()` drops immediately), but the render stack is pruned of the now-out-of-bounds keys only later, when `RenderStackManager.sync()` runs. Any render committed in that gap iterates a `keyMap` still carrying a pre-shrink `index` against the already-shortened `layouts`, so the unguarded `getLayout(index)` wired at `RecyclerView` → `LayoutManager.getLayout` throws. Upstream already guards this same staleness on the measurement path — `modifyLayout` filters stale `layoutInfo` with the comment _"layoutInfo may contain stale indices from ViewHolders that were rendered before the data shrunk"_ — but left the render path unguarded. The patch wires `ViewHolderCollection`'s `getLayout` prop to the bounds-safe `recyclerViewManager.tryGetLayout(index)` and skips (returns `null` for) any render entry whose layout is `undefined`, so a stale index is dropped for that render instead of crashing. Because `keyMap`/`LayoutManager` are shared, platform-agnostic state, the guard applies on both render branches — web's `renderEntriesRef.current.map` and native's `Array.from(renderStack.entries())`. Patch `011` (which introduces web's `renderEntriesRef` copy) only carries the index forward; it is not the source of the stale index.
-- Files changed: `src/recyclerview/RecyclerView.tsx`, `src/recyclerview/ViewHolderCollection.tsx`, and their `dist` counterparts (`dist/recyclerview/RecyclerView.js`, `dist/recyclerview/ViewHolderCollection.js`, `dist/recyclerview/ViewHolderCollection.d.ts`).
-- Upstream PR/issue: https://github.com/Shopify/flash-list/issues/2440
-- E/App issue: https://github.com/Expensify/App/issues/97472
-- Sentry: https://expensify.sentry.io/issues/APP-8PG
-- PR introducing patch: https://github.com/Expensify/App/pull/98015
diff --git a/patches/react-native-draggable-flatlist/details.md b/patches/react-native-draggable-flatlist/details.md
deleted file mode 100644
index 0f72087f6309..000000000000
--- a/patches/react-native-draggable-flatlist/details.md
+++ /dev/null
@@ -1,24 +0,0 @@
-# `react-native-draggable-flatlist` patches
-
-### [react-native-draggable-flatlist+4.0.3+001+listfooter-constraint.patch](react-native-draggable-flatlist+4.0.3+001+listfooter-constraint.patch)
-
-- Reason: Ensures items can't be dragged into the list footer by accounting for its height when constraining drag bounds.
-- Upstream PR/issue: https://github.com/computerjazz/react-native-draggable-flatlist/pull/592
-- E/App issue: 🛑
-- PR Introducing Patch: [#61380](https://github.com/Expensify/App/pull/61380)
-
-
-### [react-native-draggable-flatlist+4.0.3+002+fix-console-error-ref-measureLayout.patch](react-native-draggable-flatlist+4.0.3+002+fix-console-error-ref-measureLayout.patch)
-
-- Reason: Prevents console warning when adding a new item due to incorrect `ref.measureLayout` call.
-- Upstream PR/issue: https://github.com/computerjazz/react-native-draggable-flatlist/pull/544
-- E/App issue: 🛑
-- PR Introducing Patch: [#55066](https://github.com/Expensify/App/pull/55066)
-
-
-### [react-native-draggable-flatlist+4.0.3+003+fix-ios-autoscroll-feedback.patch](react-native-draggable-flatlist+4.0.3+003+fix-ios-autoscroll-feedback.patch)
-
-- Reason: On iOS, `scrollToOffset({animated: true})` does not emit intermediate `onScroll` events, so the `scrollOffset` shared value never updates mid-scroll. This blocks the autoscroll feedback loop (`hasScrolledToTarget` stays false), preventing waypoint reordering beyond the visible viewport. The fix manually advances `scrollOffset` after each `scrollToOffset` call to keep the loop alive.
-- Upstream PR/issue: https://github.com/computerjazz/react-native-draggable-flatlist/issues/509
-- E/App issue: [#87362](https://github.com/Expensify/App/issues/87362)
-- PR Introducing Patch: [#90617](https://github.com/Expensify/App/pull/90617)
diff --git a/patches/react-native-draggable-flatlist/react-native-draggable-flatlist+4.0.3+001+listfooter-constraint.patch b/patches/react-native-draggable-flatlist/react-native-draggable-flatlist+4.0.3+001+listfooter-constraint.patch
deleted file mode 100644
index 9706c9d87989..000000000000
--- a/patches/react-native-draggable-flatlist/react-native-draggable-flatlist+4.0.3+001+listfooter-constraint.patch
+++ /dev/null
@@ -1,115 +0,0 @@
-diff --git a/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx b/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx
-index 2f59c7a..a324da2 100644
---- a/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx
-+++ b/node_modules/react-native-draggable-flatlist/src/components/DraggableFlatList.tsx
-@@ -51,7 +51,7 @@ const AnimatedFlatList = (Animated.createAnimatedComponent(
- FlatList
- ) as unknown) as (props: RNGHFlatListProps) => React.ReactElement;
-
--function DraggableFlatListInner(props: DraggableFlatListProps) {
-+function DraggableFlatListInner({ListFooterComponent, ...props}: DraggableFlatListProps) {
- const {
- cellDataRef,
- containerRef,
-@@ -65,6 +65,7 @@ function DraggableFlatListInner(props: DraggableFlatListProps) {
- activeCellSize,
- activeIndexAnim,
- containerSize,
-+ footerSize,
- scrollOffset,
- scrollViewSize,
- spacerIndexAnim,
-@@ -162,6 +163,14 @@ function DraggableFlatListInner(props: DraggableFlatListProps) {
- props.onContainerLayout?.({ layout, containerRef });
- };
-
-+ // make size of footer available for use in drag contraints
-+ const onFooterLayout = ({
-+ nativeEvent: { layout },
-+ }: LayoutChangeEvent) => {
-+ footerSize.value = props.horizontal ? layout.width : layout.height
-+ };
-+
-+
- const onListContentSizeChange = (w: number, h: number) => {
- scrollViewSize.value = props.horizontal ? w : h;
- props.onContentSizeChange?.(w, h);
-@@ -357,6 +366,21 @@ function DraggableFlatListInner(props: DraggableFlatListProps) {
- props.onViewableItemsChanged?.(info);
- });
-
-+ // Wrap the provided ListFooterComponent to add an onLayout
-+ const wrappedListFooterComponent = ListFooterComponent ?
-+
-+ {ListFooterComponent}
-+
-+ : undefined
-+
-+ // Reset footer size when footer is removed so stale values don't affect drag constraints
-+ if (!ListFooterComponent) {
-+ footerSize.value = 0;
-+ }
-+
- return (
- (props: DraggableFlatListProps) {
- scrollEventThrottle={16}
- simultaneousHandlers={props.simultaneousHandlers}
- removeClippedSubviews={false}
-+ ListFooterComponent={wrappedListFooterComponent}
- />
- {!!props.onScrollOffsetChange && (
- () {
- const activeCellSize = useSharedValue(0); // Height or width of acctive cell
- const activeCellOffset = useSharedValue(0); // Distance between active cell and edge of container
-
-+ const footerSize = useSharedValue(0);
- const scrollOffset = useSharedValue(0);
- const scrollInit = useSharedValue(0);
-
-@@ -118,7 +119,7 @@ function useSetupAnimatedValues() {
-
- const maxTranslateNegative = -activeCellOffset.value;
- const maxTranslatePositive =
-- scrollViewSize.value - (activeCellOffset.value + activeCellSize.value);
-+ scrollViewSize.value - (activeCellOffset.value + activeCellSize.value + footerSize.value);
-
- // Only constrain the touch position while the finger is on the screen. This allows the active cell
- // to snap above/below the fold once let go, if the drag ends at the top/bottom of the screen.
-@@ -174,6 +175,7 @@ function useSetupAnimatedValues() {
- placeholderOffset,
- resetTouchedCell,
- scrollOffset,
-+ footerSize,
- scrollViewSize,
- spacerIndexAnim,
- touchPositionDiff,
-@@ -197,6 +199,7 @@ function useSetupAnimatedValues() {
- placeholderOffset,
- resetTouchedCell,
- scrollOffset,
-+ footerSize,
- scrollViewSize,
- spacerIndexAnim,
- touchPositionDiff,
-diff --git a/node_modules/react-native-draggable-flatlist/src/types.ts b/node_modules/react-native-draggable-flatlist/src/types.ts
-index d6755c8..564e3ff 100644
---- a/node_modules/react-native-draggable-flatlist/src/types.ts
-+++ b/node_modules/react-native-draggable-flatlist/src/types.ts
-@@ -52,6 +52,7 @@ export type DraggableFlatListProps = Modify<
- layout: LayoutChangeEvent["nativeEvent"]["layout"];
- containerRef: React.RefObject;
- }) => void;
-+ ListFooterComponent?: React.ReactElement;
- } & Partial
- >;
-
diff --git a/patches/react-native-draggable-flatlist/react-native-draggable-flatlist+4.0.3+002+fix-console-error-ref-measureLayout.patch b/patches/react-native-draggable-flatlist/react-native-draggable-flatlist+4.0.3+002+fix-console-error-ref-measureLayout.patch
deleted file mode 100644
index 883594ac044d..000000000000
--- a/patches/react-native-draggable-flatlist/react-native-draggable-flatlist+4.0.3+002+fix-console-error-ref-measureLayout.patch
+++ /dev/null
@@ -1,16 +0,0 @@
-diff --git a/node_modules/react-native-draggable-flatlist/src/components/NestableDraggableFlatList.tsx b/node_modules/react-native-draggable-flatlist/src/components/NestableDraggableFlatList.tsx
-index 1559352..b84ee99 100644
---- a/node_modules/react-native-draggable-flatlist/src/components/NestableDraggableFlatList.tsx
-+++ b/node_modules/react-native-draggable-flatlist/src/components/NestableDraggableFlatList.tsx
-@@ -56,6 +56,11 @@ function NestableDraggableFlatListInner(
- const onFail = () => {
- console.log("## nested draggable list measure fail");
- };
-+
-+ if (typeof nodeHandle === "number" ) {
-+ return;
-+ }
-+
- //@ts-ignore
- containerRef.current.measureLayout(nodeHandle, onSuccess, onFail);
- });
diff --git a/patches/react-native-draggable-flatlist/react-native-draggable-flatlist+4.0.3+003+fix-ios-autoscroll-feedback.patch b/patches/react-native-draggable-flatlist/react-native-draggable-flatlist+4.0.3+003+fix-ios-autoscroll-feedback.patch
deleted file mode 100644
index 4e331c5b7383..000000000000
--- a/patches/react-native-draggable-flatlist/react-native-draggable-flatlist+4.0.3+003+fix-ios-autoscroll-feedback.patch
+++ /dev/null
@@ -1,13 +0,0 @@
-diff --git a/node_modules/react-native-draggable-flatlist/src/hooks/useAutoScroll.tsx b/node_modules/react-native-draggable-flatlist/src/hooks/useAutoScroll.tsx
-index 4e30bca..b1a2f3e 100644
---- a/node_modules/react-native-draggable-flatlist/src/hooks/useAutoScroll.tsx
-+++ b/node_modules/react-native-draggable-flatlist/src/hooks/useAutoScroll.tsx
-@@ -89,6 +89,8 @@ export function useAutoScroll() {
- function scrollToInternal(offset: number) {
- if (flatlistRef && "current" in flatlistRef) {
- flatlistRef.current?.scrollToOffset({ offset, animated: true });
-+ // Manually advance scrollOffset so the autoscroll feedback loop is not blocked on iOS.
-+ scrollOffset.value = offset;
- }
- }
-
diff --git a/patches/react-native/details.md b/patches/react-native/details.md
index 4395fa022dbb..7b69f3793020 100644
--- a/patches/react-native/details.md
+++ b/patches/react-native/details.md
@@ -9,9 +9,10 @@
### [react-native+0.86.0+002+fixMVCPAndroid.patch](react-native+0.86.0+002+fixMVCPAndroid.patch)
-- Reason: Fixes content jumping issues with `MaintainVisibleContentPosition` on Android, particularly in bidirectional pagination scenarios. The patch makes two key improvements:
+- Reason: Fixes content jumping issues with `MaintainVisibleContentPosition` on Android, particularly in bidirectional pagination scenarios. The patch:
1. Changes when the first visible view is calculated - now happens on scroll events instead of during Fabric's willMountItems lifecycle, which was causing incorrect updates
2. Improves first visible view selection logic to handle Fabric's z-index-based view reordering by finding the view with the smallest position that's still greater than the scroll position
+ 3. Preserves a positioned, zero-sized first child as a scroll anchor. LegendList moves this anchor to compensate for item measurements. Selecting its surrounding container or rejecting its empty frame prevents native scroll compensation and makes the chat jump as estimated rows shrink.
- Upstream PR/issue: https://github.com/facebook/react-native/pull/46247
- E/App issue: 🛑
- PR Introducing Patch: https://github.com/Expensify/App/pull/46315 (introduced), https://github.com/Expensify/App/pull/45289 (refactored)
diff --git a/patches/react-native/react-native+0.86.0+002+fixMVCPAndroid.patch b/patches/react-native/react-native+0.86.0+002+fixMVCPAndroid.patch
index 223cce3db9b3..90b705c6df69 100644
--- a/patches/react-native/react-native+0.86.0+002+fixMVCPAndroid.patch
+++ b/patches/react-native/react-native+0.86.0+002+fixMVCPAndroid.patch
@@ -35,9 +35,16 @@ index 2bee605..ba26e7b 100644
for (i in config.minIndexForVisible until contentView.childCount) {
val child = contentView.getChildAt(i)
-@@ -128,27 +135,49 @@ internal class MaintainVisibleScrollPositionHelper(
+@@ -128,27 +135,57 @@ internal class MaintainVisibleScrollPositionHelper(
val position = if (horizontal) child.x + child.width else child.y + child.height
++ // Virtualized lists can use a zero-sized, positioned first child as their scroll anchor.
++ // Preserve it instead of choosing the container that holds all rendered items.
++ if (i == config.minIndexForVisible && child.width == 0 && child.height == 0 && position > currentScroll) {
++ firstVisibleView = child
++ break
++ }
++
// If the child is partially visible or this is the last child, select it as the anchor.
- if (position > currentScroll || i == contentView.childCount - 1) {
- firstVisibleViewRef = WeakReference(child)
@@ -70,7 +77,8 @@ index 2bee605..ba26e7b 100644
}
+ val frame = Rect()
+ firstVisibleView.getHitRect(frame)
-+ if (frame.width() > 0 || frame.height() > 0) {
++ // Zero-sized anchors have a meaningful position even though they have no area.
++ if (frame.width() > 0 || frame.height() > 0 || frame.left != 0 || frame.top != 0) {
+ prevFirstVisibleFrame = frame
+ } else {
+ prevFirstVisibleFrame = null
diff --git a/src/CONST/index.ts b/src/CONST/index.ts
index e9258be28a30..4067968dcbb2 100644
--- a/src/CONST/index.ts
+++ b/src/CONST/index.ts
@@ -1055,7 +1055,6 @@ const CONST = {
PAY_INVOICE_VIA_EXPENSIFY: 'payInvoiceViaExpensify',
SUGGESTED_FOLLOWUPS: 'suggestedFollowups',
BULK_EDIT: 'bulkEdit',
- NEW_MANUAL_EXPENSE_FLOW: 'newManualExpenseFlow',
BULK_SUBMIT_APPROVE_PAY: 'bulkSubmitApprovePay',
VENDOR_MATCHING: 'vendorMatching',
DUALENTRY: 'dualEntry',
@@ -9362,11 +9361,13 @@ const CONST = {
MERCHANT_RULE_ITEM: 'WorkspaceRules-MerchantRuleItem',
REQUIRE_FIELDS_RULE_ITEM: 'WorkspaceRules-RequireFieldsRuleItem',
REQUIRE_FIELDS_RULE_SAVE: 'WorkspaceRules-RequireFieldsRuleSave',
+ REQUIRE_FIELDS_RULE_DELETE: 'WorkspaceRules-RequireFieldsRuleDelete',
REQUIRE_FIELDS_RULE_CATEGORY: 'WorkspaceRules-RequireFieldsRuleCategory',
REQUIRE_FIELDS_RULE_FIELD_TOGGLE: 'WorkspaceRules-RequireFieldsRuleFieldToggle',
REQUIRE_FIELDS_RULE_DIRECTION_TOGGLE: 'WorkspaceRules-RequireFieldsRuleDirectionToggle',
FLAG_FOR_REVIEW_RULE_ITEM: 'WorkspaceRules-FlagForReviewRuleItem',
FLAG_FOR_REVIEW_RULE_SAVE: 'WorkspaceRules-FlagForReviewRuleSave',
+ FLAG_FOR_REVIEW_RULE_DELETE: 'WorkspaceRules-FlagForReviewRuleDelete',
FLAG_FOR_REVIEW_RULE_CATEGORY: 'WorkspaceRules-FlagForReviewRuleCategory',
FLAG_FOR_REVIEW_RULE_AMOUNT: 'WorkspaceRules-FlagForReviewRuleAmount',
FLAG_FOR_REVIEW_RULE_EXPENSE_LIMIT_TYPE: 'WorkspaceRules-FlagForReviewRuleExpenseLimitType',
@@ -9384,6 +9385,7 @@ const CONST = {
CURRENCY_SELECTOR: 'WorkspaceRules-CurrencySelector',
SPEND_RULE_SECTION_ITEM: 'WorkspaceRules-SpendRuleSectionItem',
SPEND_RULE_SAVE: 'WorkspaceRules-SpendRuleSave',
+ SPEND_RULE_DELETE: 'WorkspaceRules-SpendRuleDelete',
SPEND_RULE_RESTRICTION_TYPE: 'WorkspaceRules-SpendRuleRestrictionType',
AGENT_RULE_ITEM: 'WorkspaceRules-AgentRuleItem',
ADD_AGENT_RULE: 'WorkspaceRules-AddAgentRule',
diff --git a/src/CONST/runtimeConfigured.ts b/src/CONST/runtimeConfigured.ts
index 070d61bdf70b..b527c0fd789c 100644
--- a/src/CONST/runtimeConfigured.ts
+++ b/src/CONST/runtimeConfigured.ts
@@ -75,6 +75,7 @@ const CONST_RUNTIME: ConstRuntime = {
SCREENS.AI_FEATURES_PROMO_MODAL.DYNAMIC_ROOT,
SCREENS.MONEY_REQUEST.DYNAMIC_STEP_SCAN,
SCREENS.DOMAIN.MEMBERS_MOVE_TO_GROUP,
+ SCREENS.PRE_MOUNT_BUFFER,
...Object.values(SCREENS.MULTIFACTOR_AUTHENTICATION),
],
};
diff --git a/src/CONST/runtimeDefaults.ts b/src/CONST/runtimeDefaults.ts
index 0562c91da7c8..e94804ef6431 100644
--- a/src/CONST/runtimeDefaults.ts
+++ b/src/CONST/runtimeDefaults.ts
@@ -98,6 +98,7 @@ const CONST_RUNTIME_DEFAULTS: ConstRuntime = {
'Dynamic_AIFeaturesPromoModal_Root',
'Money_Request_Step_Scan',
'Members_Move_To_Group',
+ 'PreMountBuffer',
'Multifactor_Authentication_Validate_Code',
'Multifactor_Authentication_Outcome_Success',
'Multifactor_Authentication_Outcome_Failure',
diff --git a/src/DeepLinkHandler.tsx b/src/DeepLinkHandler.tsx
index f674b3fe6f44..62d9f6d2acc2 100644
--- a/src/DeepLinkHandler.tsx
+++ b/src/DeepLinkHandler.tsx
@@ -215,6 +215,8 @@ function DeepLinkHandler({onInitialUrl}: DeepLinkHandlerProps) {
introSelected,
betas,
conciergeChat,
+ // The public room already exists on the server, so no optimistic report is created and the personal details are never read.
+ personalDetails: undefined,
hasReportActions: false,
currentUserAccountID: session?.accountID ?? CONST.DEFAULT_NUMBER_ID,
isSelfTourViewed: guidedSetupAndTourStatus?.isSelfTourViewed,
diff --git a/src/ONYXKEYS.ts b/src/ONYXKEYS.ts
index 5a559f98b0ef..a0a20048e485 100755
--- a/src/ONYXKEYS.ts
+++ b/src/ONYXKEYS.ts
@@ -619,6 +619,9 @@ const ONYXKEYS = {
/** Indicates whether the debug mode is currently enabled */
IS_DEBUG_MODE_ENABLED: 'isDebugModeEnabled',
+ /** Local overrides for beta feature flags, set from the Test Tool Menu on dev/staging. Takes precedence over the server-provided betas */
+ BETA_OVERRIDES: 'betaOverrides',
+
/** Indicates whether the git branch name should be shown in the browser tab title */
SHOULD_SHOW_BRANCH_NAME_IN_TITLE: 'shouldShowBranchNameInTitle',
@@ -768,6 +771,9 @@ const ONYXKEYS = {
/** Information about travel provisioning process */
TRAVEL_PROVISIONING: 'travelProvisioning',
+ /** Signals the UI to show the Enable Global Reimbursements modal when a pay attempt fails because the workspace USD VBBA is not set up on Corpay */
+ RAM_ONLY_CORPAY_PAY_MODAL: 'corpayPayModal',
+
/** Stores the information about the state of side panel */
NVP_SIDE_PANEL: 'nvp_sidePanel',
@@ -899,6 +905,12 @@ const ONYXKEYS = {
REPORT: 'report_',
REPORT_NAME_VALUE_PAIRS: 'reportNameValuePairs_',
REPORT_DRAFT: 'reportDraft_',
+ // Boolean marker (no report data) flagging that report_ is a speculative copy of reportDraft_, written by
+ // preMountDraftReport so a pre-mounted destination can render before submit. Must persist (not RAM-only): the
+ // report_ row it points at lives in the persisted REPORT collection, and Onyx RAM-only applies per key or whole
+ // collection, not per row, so that row survives an app kill (where no unmount cleanup runs). A RAM-only marker would
+ // vanish while the row stays behind. The next launch uses this marker to find and delete it.
+ REPORT_PRE_MOUNTED_DRAFT: 'reportPreMountedDraft_',
// REPORT_METADATA holds report-level business state that is NOT the report itself
// (optimistic flag, pending chat members, report-level errors, DEW pendingExpenseAction).
// Loading flags / pagination cursors / last-visit timestamp live in dedicated
@@ -1494,6 +1506,7 @@ type OnyxCollectionValuesMapping = {
[ONYXKEYS.COLLECTION.REPORT]: OnyxTypes.Report;
[ONYXKEYS.COLLECTION.REPORT_NAME_VALUE_PAIRS]: OnyxTypes.ReportNameValuePairs;
[ONYXKEYS.COLLECTION.REPORT_DRAFT]: OnyxTypes.Report;
+ [ONYXKEYS.COLLECTION.REPORT_PRE_MOUNTED_DRAFT]: boolean;
[ONYXKEYS.COLLECTION.REPORT_METADATA]: OnyxTypes.ReportMetadata;
[ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE]: OnyxTypes.ReportLoadingState;
[ONYXKEYS.COLLECTION.RAM_ONLY_COMPANY_CARDS_LOADING_STATE]: OnyxTypes.CompanyCardsLoadingState;
@@ -1755,6 +1768,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.ACTIVE_SERVER]: ValueOf;
[ONYXKEYS.CLOUDFLARE_SESSION]: OnyxTypes.CloudflareSession;
[ONYXKEYS.IS_DEBUG_MODE_ENABLED]: boolean;
+ [ONYXKEYS.BETA_OVERRIDES]: OnyxTypes.BetaOverrides;
[ONYXKEYS.SHOULD_SHOW_BRANCH_NAME_IN_TITLE]: boolean;
[ONYXKEYS.IS_SENTRY_DEBUG_ENABLED]: boolean;
[ONYXKEYS.IS_SENTRY_SEND_ENABLED]: boolean;
@@ -1815,6 +1829,7 @@ type OnyxValuesMapping = {
[ONYXKEYS.CORPAY_ONBOARDING_FIELDS]: OnyxTypes.CorpayOnboardingFields;
[ONYXKEYS.LAST_FULL_RECONNECT_TIME]: string;
[ONYXKEYS.TRAVEL_PROVISIONING]: OnyxTypes.TravelProvisioning;
+ [ONYXKEYS.RAM_ONLY_CORPAY_PAY_MODAL]: OnyxTypes.CorpayPayModal;
[ONYXKEYS.IS_LOADING_BILL_WHEN_DOWNGRADE]: boolean | undefined;
[ONYXKEYS.SHOULD_BILL_WHEN_DOWNGRADING]: boolean | undefined;
[ONYXKEYS.BILLING_RECEIPT_DETAILS]: OnyxTypes.BillingReceiptDetails;
diff --git a/src/ROUTES.ts b/src/ROUTES.ts
index 237ebb350a8e..6f1c83cb8fd2 100644
--- a/src/ROUTES.ts
+++ b/src/ROUTES.ts
@@ -93,6 +93,16 @@ type DynamicRoutes = Record;
* Avoid for: regular navigation, single-entry workflows
*
*/
+const ENABLE_GLOBAL_REIMBURSEMENTS_ENTRY_SCREENS = [
+ SCREENS.REPORT,
+ SCREENS.RIGHT_MODAL.SEARCH_REPORT,
+ SCREENS.RIGHT_MODAL.EXPENSE_REPORT,
+ SCREENS.RIGHT_MODAL.SEARCH_MONEY_REQUEST_REPORT,
+ SCREENS.HOME,
+ SCREENS.SEARCH.ROOT,
+ SCREENS.SETTINGS.WALLET.ROOT,
+] as const;
+
const DYNAMIC_ROUTES = {
VERIFY_ACCOUNT: {
path: 'verify-account',
@@ -173,6 +183,46 @@ const DYNAMIC_ROUTES = {
}),
queryParams: ['shouldSkipPurposeSelection', 'shouldSetUpUSBankAccount'],
},
+ ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS: {
+ path: 'enable-global-reimbursements/business/:bankAccountID/:subPage/:action?',
+ entryScreens: ENABLE_GLOBAL_REIMBURSEMENTS_ENTRY_SCREENS,
+ getRoute: (bankAccountID: string | number, subPage: string, action?: 'edit', params?: {bankCountry?: string; bankCurrency?: string}) =>
+ getUrlWithParams(`enable-global-reimbursements/business/${bankAccountID}/${subPage}${action ? `/${action}` : ''}`, {
+ bankCountry: params?.bankCountry,
+ bankCurrency: params?.bankCurrency,
+ }),
+ queryParams: ['bankCountry', 'bankCurrency'],
+ },
+ ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS: {
+ path: 'enable-global-reimbursements/agreements/:bankAccountID',
+ entryScreens: [
+ ...ENABLE_GLOBAL_REIMBURSEMENTS_ENTRY_SCREENS,
+ SCREENS.SETTINGS.WALLET.ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS,
+ SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS,
+ ],
+ getRoute: (bankAccountID: string | number, params?: {bankCountry?: string; bankCurrency?: string}) =>
+ getUrlWithParams(`enable-global-reimbursements/agreements/${bankAccountID}`, {
+ bankCountry: params?.bankCountry,
+ bankCurrency: params?.bankCurrency,
+ }),
+ queryParams: ['bankCountry', 'bankCurrency'],
+ },
+ ENABLE_GLOBAL_REIMBURSEMENTS_SIGN: {
+ path: 'enable-global-reimbursements/sign/:bankAccountID',
+ entryScreens: [
+ ...ENABLE_GLOBAL_REIMBURSEMENTS_ENTRY_SCREENS,
+ SCREENS.SETTINGS.WALLET.ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS,
+ SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS,
+ SCREENS.SETTINGS.WALLET.ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS,
+ SCREENS.SETTINGS.WALLET.DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS,
+ ],
+ getRoute: (bankAccountID: string | number, params?: {bankCountry?: string; bankCurrency?: string}) =>
+ getUrlWithParams(`enable-global-reimbursements/sign/${bankAccountID}`, {
+ bankCountry: params?.bankCountry,
+ bankCurrency: params?.bankCurrency,
+ }),
+ queryParams: ['bankCountry', 'bankCurrency'],
+ },
BANK_ACCOUNT_VERIFY_ACCOUNT: {
path: 'verify-bank-account',
entryScreens: [SCREENS.REIMBURSEMENT_ACCOUNT],
@@ -2323,16 +2373,27 @@ const ROUTES = {
},
SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS: {
route: 'settings/wallet/:bankAccountID/enable-global-reimbursements/business/:subPage/:action?',
- getRoute: (bankAccountID: number | undefined, subPage: string, action?: 'edit') =>
- `settings/wallet/${bankAccountID}/enable-global-reimbursements/business/${subPage}${action ? `/${action}` : ''}` as const,
+ getRoute: (bankAccountID: number | undefined, subPage: string, action?: 'edit', params?: {bankCountry?: string; bankCurrency?: string}) =>
+ getUrlWithParams(`settings/wallet/${bankAccountID}/enable-global-reimbursements/business/${subPage}${action ? `/${action}` : ''}`, {
+ bankCountry: params?.bankCountry,
+ bankCurrency: params?.bankCurrency,
+ }),
},
SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS: {
route: 'settings/wallet/:bankAccountID/enable-global-reimbursements/agreements',
- getRoute: (bankAccountID: number | undefined) => `settings/wallet/${bankAccountID}/enable-global-reimbursements/agreements` as const,
+ getRoute: (bankAccountID: number | undefined, params?: {bankCountry?: string; bankCurrency?: string}) =>
+ getUrlWithParams(`settings/wallet/${bankAccountID}/enable-global-reimbursements/agreements`, {
+ bankCountry: params?.bankCountry,
+ bankCurrency: params?.bankCurrency,
+ }),
},
SETTINGS_WALLET_ENABLE_GLOBAL_REIMBURSEMENTS_SIGN: {
route: 'settings/wallet/:bankAccountID/enable-global-reimbursements/sign',
- getRoute: (bankAccountID: number | undefined) => `settings/wallet/${bankAccountID}/enable-global-reimbursements/sign` as const,
+ getRoute: (bankAccountID: number | undefined, params?: {bankCountry?: string; bankCurrency?: string}) =>
+ getUrlWithParams(`settings/wallet/${bankAccountID}/enable-global-reimbursements/sign`, {
+ bankCountry: params?.bankCountry,
+ bankCurrency: params?.bankCurrency,
+ }),
},
SETTINGS_WALLET_SHARE_BANK_ACCOUNT: {
route: 'settings/wallet/:bankAccountID/share-bank-account',
@@ -2496,6 +2557,7 @@ const ROUTES = {
SETTINGS_STATUS_CLEAR_AFTER_TIME: 'settings/profile/status/clear-after/time',
SETTINGS_VACATION_DELEGATE: 'settings/profile/status/vacation-delegate',
SETTINGS_TROUBLESHOOT: 'settings/troubleshoot',
+ SETTINGS_TROUBLESHOOT_BETA_OVERRIDES: 'settings/troubleshoot/beta-overrides',
SETTINGS_HELP: 'settings/help',
SETTINGS_SAVE_THE_WORLD: 'settings/teachersunite',
@@ -2510,7 +2572,7 @@ const ROUTES = {
REPORT: 'r',
REPORT_WITH_ID: {
route: 'r/:reportID?/:reportActionID?',
- getRoute: (reportID: string | undefined, reportActionID?: string, referrer?: string, backTo?: string, secureKey?: string) => {
+ getRoute: (reportID: string | undefined, reportActionID?: string, referrer?: string, backTo?: string, secureKey?: string, isPendingCreation?: boolean) => {
if (!reportID) {
Log.warn('Invalid reportID is used to build the REPORT_WITH_ID route');
return getUrlWithBackToParam(ROUTES.HOME, backTo);
@@ -2525,6 +2587,9 @@ const ROUTES = {
if (secureKey) {
queryParams.push(`secureKey=${encodeURIComponent(secureKey)}`);
}
+ if (isPendingCreation) {
+ queryParams.push('isPendingCreation=true');
+ }
const queryString = queryParams.length > 0 ? `?${queryParams.join('&')}` : '';
diff --git a/src/SCREENS.ts b/src/SCREENS.ts
index 6336a927a863..ac03bc5ee366 100644
--- a/src/SCREENS.ts
+++ b/src/SCREENS.ts
@@ -189,6 +189,9 @@ const SCREENS = {
ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS: 'Settings_Wallet_Enable_Global_Reimbursements_Business',
ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS: 'Settings_Wallet_Enable_Global_Reimbursements_Agreements',
ENABLE_GLOBAL_REIMBURSEMENTS_SIGN: 'Settings_Wallet_Enable_Global_Reimbursements_Sign',
+ DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_BUSINESS: 'Dynamic_Settings_Wallet_Enable_Global_Reimbursements_Business',
+ DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_AGREEMENTS: 'Dynamic_Settings_Wallet_Enable_Global_Reimbursements_Agreements',
+ DYNAMIC_ENABLE_GLOBAL_REIMBURSEMENTS_SIGN: 'Dynamic_Settings_Wallet_Enable_Global_Reimbursements_Sign',
SHARE_BANK_ACCOUNT: 'Settings_Wallet_Share_Bank_Account',
TRAVEL_CVV: 'Settings_Wallet_Travel_CVV',
TRAVEL_CVV_VERIFY_ACCOUNT: 'Settings_Wallet_Travel_CVV_VerifyAccount',
@@ -304,6 +307,7 @@ const SCREENS = {
REPORT_EXPORT: 'Report_Export',
MISSING_PERSONAL_DETAILS: 'MissingPersonalDetails',
DEBUG: 'Debug',
+ BETA_OVERRIDES: 'BetaOverrides',
ADD_EXISTING_EXPENSE: 'AddExistingExpense',
SCHEDULE_CALL: 'ScheduleCall',
REPORT_CHANGE_APPROVER: 'Report_Change_Approver',
@@ -316,6 +320,7 @@ const SCREENS = {
CHRONOS_SCHEDULE_OOO: 'Chronos_Schedule_OOO',
AVATAR_CROP: 'AvatarCrop',
},
+ PRE_MOUNT_BUFFER: 'PreMountBuffer',
REPORT_CARD_ACTIVATE: 'Report_Card_Activate_Root',
SAML_SIGN_IN: 'SAMLSignIn',
WORKSPACE_JOIN_USER: 'WorkspaceJoinUser',
diff --git a/src/components/AmountForm.tsx b/src/components/AmountForm.tsx
index d2889a00b939..6dc96e12be9d 100644
--- a/src/components/AmountForm.tsx
+++ b/src/components/AmountForm.tsx
@@ -14,6 +14,7 @@ import type {NumberWithSymbolFormRef} from './NumberWithSymbolForm';
import type {BaseTextInputProps, BaseTextInputRef} from './TextInput/BaseTextInput/types';
import NumberWithSymbolForm from './NumberWithSymbolForm';
+import NumericField from './NumericField';
type AmountFormProps = {
/** Amount supplied by the FormProvider */
@@ -70,10 +71,10 @@ type AmountFormProps = {
/** Callback when the input is focused */
onFocus?: () => void;
-} & Pick;
+} & Pick;
/**
- * Wrapper around NumberWithSymbolForm with currency handling.
+ * Wrapper around the numeric form components with currency handling.
*/
function AmountForm({
value,
@@ -92,8 +93,6 @@ function AmountForm({
currencyButtonAccessibilityLabel,
disabled = false,
autoFocus,
- autoGrowExtraSpace,
- autoGrowMarginSide,
onSubmitEditing,
onFocus,
onBlur,
@@ -104,6 +103,33 @@ function AmountForm({
const styles = useThemeStyles();
const {getCurrencyDecimals} = useCurrencyListActions();
const decimals = decimalsProp ?? getCurrencyDecimals(currency);
+ const symbol = getLocalizedCurrencySymbol(preferredLocale, currency) ?? '';
+
+ // Use NumericField for standard text input. Currency-button variants still use the legacy form.
+ if (displayAsTextInput && !shouldShowCurrencyButton) {
+ return (
+
+
+
+ );
+ }
return (
= React.ComponentType> | null | undefined;
-
-type CellRendererConfig = {
- itemLayoutAnimation?: ILayoutAnimationBuilder;
- outerCellRenderer?: CellRendererComponentProps;
-};
-
-const CellRendererConfigContext = createContext({});
-
-/**
- * Module-scope cell renderer so OXC's React Compiler can discover and memoize it.
- * `itemLayoutAnimation` and the optional outer renderer are read from context because
- * FlatList only passes standard cell props to `CellRendererComponent`.
- */
-function CellRendererComponentImpl(props: RNCellRendererProps) {
- const {itemLayoutAnimation, outerCellRenderer: OuterCellRenderer} = useContext(CellRendererConfigContext);
-
- return (
-
- {OuterCellRenderer ? {props.children} : props.children}
-
- );
-}
-
-type ReanimatedFlatListPropsWithLayout = {
- /**
- * Lets you pass layout animation directly to the FlatList item.
- */
- itemLayoutAnimation?: ILayoutAnimationBuilder;
- /**
- * Lets you skip entering and exiting animations of FlatList items when on FlatList mount or unmount.
- */
- skipEnteringExitingAnimations?: boolean;
-} & AnimatedProps>;
-
-// Since createAnimatedComponent return type is ComponentClass that has the props of the argument,
-// but not things like NativeMethods, etc. we need to add them manually by extending the type.
-
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-type AnimatedFlatListWithCellRendererProps- = Omit, 'CellRendererComponent' | 'onScroll' | 'inverted'> & {
- CellRendererComponent?: CellRendererComponentProps
- ;
- onScroll?: FlatListProps
- ['onScroll'];
- inverted?: boolean | null | undefined;
- ref?: Ref;
-};
-
-/**
- * Non-generic implementation so OXC's React Compiler can memoize the component.
- * OXC bails on type params inside components ("Unsupported declaration type for hoisting").
- */
-function FlatListRenderImpl(props: AnimatedFlatListWithCellRendererProps) {
- const {itemLayoutAnimation, skipEnteringExitingAnimations, ref, CellRendererComponent: outerCellRenderer, ...restProps} = props;
-
- // Set default scrollEventThrottle, because user expects
- // to have continuous scroll events and
- // react-native defaults it to 50 for FlatLists.
- // We set it to 1, so we have peace until
- // there are 960 fps screens.
- if (!('scrollEventThrottle' in restProps)) {
- restProps.scrollEventThrottle = 1;
- }
-
- const cellRendererConfig: CellRendererConfig = {itemLayoutAnimation, outerCellRenderer};
-
- const animatedFlatList = (
-
-
-
- );
-
- if (skipEnteringExitingAnimations === undefined) {
- return animatedFlatList;
- }
-
- return (
-
- {animatedFlatList}
-
- );
-}
-
-// We need explicit any here, because this is the exact same type that is used in React Native types.
-// eslint-disable-next-line @typescript-eslint/no-explicit-any
-function FlatListRender
- (props: AnimatedFlatListWithCellRendererProps
- ) {
- return )} />;
-}
-
-const AnimatedFlatListWithCellRenderer = FlatListRender as <
- // We need explicit any here, because this is the exact same type that is used in React Native types.
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- ItemT = any,
->(
- props: AnimatedFlatListWithCellRendererProps,
-) => React.ReactElement;
-
-export type {AnimatedFlatListWithCellRendererProps};
-
-export default AnimatedFlatListWithCellRenderer;
diff --git a/src/components/AttachmentPicker/index.native.tsx b/src/components/AttachmentPicker/index.native.tsx
index 164a88b12a09..ca2437f5e61d 100644
--- a/src/components/AttachmentPicker/index.native.tsx
+++ b/src/components/AttachmentPicker/index.native.tsx
@@ -11,9 +11,9 @@ import useStyleUtils from '@hooks/useStyleUtils';
import useTheme from '@hooks/useTheme';
import useThemeStyles from '@hooks/useThemeStyles';
-import {cleanFileName, showCameraPermissionsAlert, verifyFileFormat} from '@libs/fileDownload/FileUtils';
+import {cleanFileName, showCameraPermissionsAlert} from '@libs/fileDownload/FileUtils';
+import processPickedAssetsSequentially from '@libs/fileDownload/processPickedAssets';
import fileURIToPath from '@libs/fileURIToPath';
-import Log from '@libs/Log';
import ReceiptStorage from '@libs/ReceiptStorage';
import {getPickerCaptureSource, logReceiptAdoptFailed} from '@libs/telemetry/ReceiptObservability';
@@ -27,7 +27,6 @@ import type {Asset, Callback, CameraOptions, ImageLibraryOptions, ImagePickerRes
import {keepLocalCopy, pick, types} from '@react-native-documents/picker';
import {Str} from 'expensify-common';
-import {ImageManipulator, SaveFormat} from 'expo-image-manipulator';
import React, {useCallback, useMemo, useRef, useState} from 'react';
import {Alert, View} from 'react-native';
import RNFetchBlob from 'react-native-blob-util';
@@ -70,27 +69,6 @@ type Item = {
textTranslationKey: TranslationPaths;
pickAttachment: () => Promise;
};
-
-/**
- * Ensures asset has proper fileName and type properties
- */
-const processAssetWithFallbacks = (asset: Asset): Asset => {
- // Generate fallback name: extract from URI if available, otherwise use timestamped default
- const fallbackName = asset.uri
- ? asset.uri
- .substring(asset.uri.lastIndexOf('/') + 1)
- .split('?')
- .at(0)
- : `image_${Date.now()}.jpeg`;
- const fileName = asset.fileName ?? fallbackName;
- return {
- ...asset,
- fileName,
- // Default to JPEG if no type specified
- type: asset.type ?? 'image/jpeg',
- };
-};
-
/**
* Return imagePickerOptions based on the type
*/
@@ -223,68 +201,7 @@ function AttachmentPicker({
return resolve();
}
- const processedAssets: Asset[] = [];
- let processedCount = 0;
-
- const checkAllProcessed = () => {
- processedCount++;
- if (processedCount === assets.length) {
- resolve(processedAssets.length > 0 ? processedAssets : undefined);
- }
- };
-
- for (const asset of assets) {
- if (!asset.uri) {
- checkAllProcessed();
- continue;
- }
-
- if (asset.type?.startsWith('image')) {
- verifyFileFormat({fileUri: asset.uri, formatSignatures: CONST.HEIC_SIGNATURES})
- .then((isHEIC) => {
- // react-native-image-picker incorrectly changes file extension without transcoding the HEIC file, so we are doing it manually if we detect HEIC signature
- if (isHEIC && asset.uri) {
- ImageManipulator.manipulate(asset.uri)
- .renderAsync()
- .then((manipulatedImage) => manipulatedImage.saveAsync({format: SaveFormat.JPEG}))
- .then((manipulationResult) => {
- const uri = manipulationResult.uri;
- const convertedAsset = {
- uri,
- name: uri
- .substring(uri.lastIndexOf('/') + 1)
- .split('?')
- .at(0),
- type: 'image/jpeg',
- width: manipulationResult.width,
- height: manipulationResult.height,
- };
- processedAssets.push(convertedAsset);
- checkAllProcessed();
- })
- .catch((error: Error) => {
- Log.warn('Failed to convert HEIC image, skipping asset', {error: error.message});
- showGeneralAlert(translate('attachmentPicker.errorWhileConvertingHeic'));
- checkAllProcessed();
- });
- } else {
- // Ensure the asset has proper fileName and type for non-HEIC images
- const processedAsset = processAssetWithFallbacks(asset);
- processedAssets.push(processedAsset);
- checkAllProcessed();
- }
- })
- .catch((error: Error) => {
- showGeneralAlert(error.message ?? 'An unknown error occurred');
- checkAllProcessed();
- });
- } else {
- // Ensure the asset has proper fileName and type
- const processedAsset = processAssetWithFallbacks(asset);
- processedAssets.push(processedAsset);
- checkAllProcessed();
- }
- }
+ processPickedAssetsSequentially(assets, showGeneralAlert, translate).then(resolve).catch(reject);
});
}),
[fileLimit, showGeneralAlert, translate, type],
diff --git a/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/getAttachmentCarouselPageIndex.ts b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/getAttachmentCarouselPageIndex.ts
new file mode 100644
index 000000000000..b6ad225dfe6f
--- /dev/null
+++ b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/getAttachmentCarouselPageIndex.ts
@@ -0,0 +1,25 @@
+const MIN_FLING_VELOCITY = 500;
+
+type GetAttachmentCarouselPageIndexParams = {
+ cellWidth: number;
+ itemCount: number;
+ page: number;
+ translationX: number;
+ velocityX: number;
+};
+
+function getAttachmentCarouselPageIndex({cellWidth, itemCount, page, translationX, velocityX}: GetAttachmentCarouselPageIndexParams): number {
+ 'worklet';
+
+ if (velocityX > MIN_FLING_VELOCITY) {
+ return Math.max(0, page - 1);
+ }
+ if (velocityX < -MIN_FLING_VELOCITY) {
+ return Math.min(itemCount - 1, page + 1);
+ }
+
+ const pageDelta = Math.round(-translationX / cellWidth);
+ return Math.min(itemCount - 1, Math.max(0, page + pageDelta));
+}
+
+export default getAttachmentCarouselPageIndex;
diff --git a/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx
index b8cb4603a045..914cb622c17c 100644
--- a/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx
+++ b/src/components/Attachments/AttachmentCarousel/AttachmentCarouselView/index.tsx
@@ -19,27 +19,27 @@ import {canUseTouchScreen as canUseTouchScreenUtil} from '@libs/DeviceCapabiliti
import variables from '@styles/variables';
-import CONST from '@src/CONST';
-
+import type {LegendListRef, LegendListRenderItemProps} from '@legendapp/list/react-native';
import type {RefObject} from 'react';
-import type {ListRenderItemInfo} from 'react-native';
import type {ComposedGesture, GestureType} from 'react-native-gesture-handler';
+import type Animated from 'react-native-reanimated';
+import {AnimatedLegendList} from '@legendapp/list/reanimated';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
import {Keyboard, PixelRatio, View} from 'react-native';
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
-import Animated, {scrollTo, useAnimatedRef, useSharedValue} from 'react-native-reanimated';
+import {scrollTo, useAnimatedRef, useSharedValue} from 'react-native-reanimated';
import type AttachmentCarouselViewProps from './types';
+import getAttachmentCarouselPageIndex from './getAttachmentCarouselPageIndex';
+
const viewabilityConfig = {
// To facilitate paging through the attachments, we want to consider an item "viewable" when it is
// more than 95% visible. When that happens we update the page index in the state.
itemVisiblePercentThreshold: 95,
};
-const MIN_FLING_VELOCITY = 500;
-
type DeviceAwareGestureDetectorProps = {
canUseTouchScreen: boolean;
gesture: ComposedGesture | GestureType;
@@ -79,7 +79,8 @@ function AttachmentCarouselView({
const [activeAttachmentID, setActiveAttachmentID] = useState(attachmentID ?? source);
const pagerRef = useRef(null);
- const scrollRef = useAnimatedRef>>();
+ const listRef = useRef(null);
+ const scrollRef = useAnimatedRef>();
const {shouldUseNarrowLayout} = useResponsiveLayout();
const modalStyles = styles.centeredModalStyles(shouldUseNarrowLayout, true);
@@ -130,13 +131,13 @@ function AttachmentCarouselView({
const nextIndex = page + deltaSlide;
const nextItem = attachments.at(nextIndex);
- if (!nextItem || nextIndex < 0 || !scrollRef.current) {
+ if (!nextItem || nextIndex < 0 || !listRef.current) {
return;
}
- scrollRef.current.scrollToIndex({index: nextIndex, animated: canUseTouchScreen});
+ listRef.current.scrollToIndex({index: nextIndex, animated: canUseTouchScreen});
},
- [attachments, canUseTouchScreen, isFullScreen, page, scrollRef],
+ [attachments, canUseTouchScreen, isFullScreen, page],
);
const extractItemKey = useCallback(
@@ -147,16 +148,6 @@ function AttachmentCarouselView({
[],
);
- /** Calculate items layout information to optimize scrolling performance */
- const getItemLayout = useCallback(
- (data: ArrayLike | null | undefined, index: number) => ({
- length: cellWidth,
- offset: cellWidth * index,
- index,
- }),
- [cellWidth],
- );
-
const stateValue = useMemo(
() => ({
pagerItems: [{source, index: 0, isActive: true}],
@@ -180,7 +171,7 @@ function AttachmentCarouselView({
/** Defines how a single attachment should be rendered */
const renderItem = useCallback(
- ({item}: ListRenderItemInfo) => (
+ ({item}: LegendListRenderItemProps) => (
MIN_FLING_VELOCITY) {
- // User flung to the right
- newIndex = Math.max(0, page - 1);
- } else if (velocityX < -MIN_FLING_VELOCITY) {
- // User flung to the left
- newIndex = Math.min(attachments.length - 1, page + 1);
- } else {
- // snap scroll position to the nearest cell (making sure it's within the bounds of the list)
- const delta = Math.round(-translationX / cellWidth);
- newIndex = Math.min(attachments.length - 1, Math.max(0, page + delta));
- }
+ const newIndex = getAttachmentCarouselPageIndex({cellWidth, itemCount: attachments.length, page, translationX, velocityX});
isPagerScrolling.set(false);
scrollTo(scrollRef, newIndex * cellWidth, 0, true);
@@ -236,11 +216,11 @@ function AttachmentCarouselView({
// Scroll position is affected when window width is resized, so we readjust it on width changes
useEffect(() => {
- if (attachments.length === 0 || scrollRef.current == null) {
+ if (attachments.length === 0 || listRef.current == null) {
return;
}
- scrollRef.current.scrollToIndex({index: page, animated: false});
+ listRef.current.scrollToIndex({index: page, animated: false});
// The hook is not supposed to run on page change, so we keep the page out of the dependencies
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [cellWidth]);
@@ -275,20 +255,19 @@ function AttachmentCarouselView({
canUseTouchScreen={canUseTouchScreen}
gesture={pan}
>
- cellWidth}
keyExtractor={extractItemKey}
viewabilityConfig={viewabilityConfig}
onViewableItemsChanged={updatePage}
diff --git a/src/components/Attachments/AttachmentCarousel/types.ts b/src/components/Attachments/AttachmentCarousel/types.ts
index 1df610f7338c..cdd123e84106 100644
--- a/src/components/Attachments/AttachmentCarousel/types.ts
+++ b/src/components/Attachments/AttachmentCarousel/types.ts
@@ -3,7 +3,7 @@ import type {Attachment, AttachmentSource} from '@components/Attachments/types';
import type CONST from '@src/CONST';
import type {Report} from '@src/types/onyx';
-import type {ViewToken} from 'react-native';
+import type {ViewToken} from '@legendapp/list/react-native';
import type {ValueOf} from 'type-fest';
type UpdatePageProps = {
diff --git a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx
index 142fefc881bb..2c7833f61594 100644
--- a/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx
+++ b/src/components/AutoCompleteSuggestions/BaseAutoCompleteSuggestions.tsx
@@ -8,10 +8,11 @@ import {hasHoverSupport} from '@libs/DeviceCapabilities';
import CONST from '@src/CONST';
+import type {LegendListRef} from '@legendapp/list/react-native';
import type {ReactElement} from 'react';
-import React, {useCallback, useEffect, useRef} from 'react';
-import {FlatList} from 'react-native-gesture-handler';
+import {LegendList} from '@legendapp/list/react-native';
+import React, {useEffect, useRef} from 'react';
import Animated, {Easing, useAnimatedStyle, useSharedValue, withTiming} from 'react-native-reanimated';
import type {AutoCompleteSuggestionsPortalProps} from './AutoCompleteSuggestionsPortal';
@@ -37,35 +38,29 @@ function BaseAutoCompleteSuggestionsImpl({
const rowHeight = useSharedValue(0);
const prevRowHeightRef = useRef(measuredHeightOfSuggestionRows);
const fadeInOpacity = useSharedValue(0);
- const scrollRef = useRef>(null);
+ const scrollRef = useRef(null);
/**
* Render a suggestion menu item component.
*/
- const renderItem = useCallback(
- ({item, index}: RenderSuggestionMenuItemProps): ReactElement => (
- StyleUtils.getAutoCompleteSuggestionItemStyle(highlightedSuggestionIndex, CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT, hovered, index)}
- hoverDimmingValue={1}
- onMouseDown={(e) => e.preventDefault()}
- onPress={() => onSelect(index)}
- onLongPress={() => {}}
- accessibilityLabel={accessibilityLabelExtractor(item, index)}
- role={CONST.ROLE.MENUITEM}
- sentryLabel={CONST.SENTRY_LABEL.BASE_AUTO_COMPLETE_SUGGESTIONS.MENU_ITEM}
- >
- {renderSuggestionMenuItem(item, index)}
-
- ),
- [accessibilityLabelExtractor, renderSuggestionMenuItem, StyleUtils, highlightedSuggestionIndex, onSelect],
+ const renderItem = ({item, index}: RenderSuggestionMenuItemProps): ReactElement => (
+ StyleUtils.getAutoCompleteSuggestionItemStyle(highlightedSuggestionIndex, CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT, hovered, index)}
+ hoverDimmingValue={1}
+ onMouseDown={(e) => e.preventDefault()}
+ onPress={() => onSelect(index)}
+ onLongPress={() => {}}
+ accessibilityLabel={accessibilityLabelExtractor(item, index)}
+ role={CONST.ROLE.MENUITEM}
+ sentryLabel={CONST.SENTRY_LABEL.BASE_AUTO_COMPLETE_SUGGESTIONS.MENU_ITEM}
+ >
+ {renderSuggestionMenuItem(item, index)}
+
);
-
const innerHeight = CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT * suggestions.length;
-
const animatedStyles = useAnimatedStyle(() => ({
opacity: fadeInOpacity.get(),
...StyleUtils.getAutoCompleteSuggestionContainerStyle(rowHeight.get()),
}));
-
useEffect(() => {
if (measuredHeightOfSuggestionRows === prevRowHeightRef.current) {
fadeInOpacity.set(
@@ -84,20 +79,20 @@ function BaseAutoCompleteSuggestionsImpl({
}),
);
}
-
prevRowHeightRef.current = measuredHeightOfSuggestionRows;
}, [suggestions.length, rowHeight, measuredHeightOfSuggestionRows, prevRowHeightRef, fadeInOpacity]);
-
useEffect(() => {
if (!scrollRef.current) {
return;
}
// When using cursor control (moving the cursor with the space bar on the keyboard) on Android, moving the cursor too fast may cause an error.
try {
- scrollRef.current.scrollToIndex({index: highlightedSuggestionIndex, animated: true});
+ scrollRef.current.scrollToIndex({
+ index: highlightedSuggestionIndex,
+ animated: true,
+ });
} catch (e) {}
}, [highlightedSuggestionIndex]);
-
return (
- CONST.AUTO_COMPLETE_SUGGESTER.SUGGESTION_ROW_HEIGHT}
showsVerticalScrollIndicator={innerHeight > rowHeight.get()}
- extraData={[highlightedSuggestionIndex, renderSuggestionMenuItem]}
+ extraData={renderItem}
style={styles.overscrollBehaviorContain}
/>
);
}
-
function BaseAutoCompleteSuggestions(props: ExternalProps) {
return )} />;
}
-
export default BaseAutoCompleteSuggestions;
diff --git a/src/components/ConfirmationPage.tsx b/src/components/ConfirmationPage.tsx
index f147200c9fb4..fdb8a8deea26 100644
--- a/src/components/ConfirmationPage.tsx
+++ b/src/components/ConfirmationPage.tsx
@@ -54,6 +54,12 @@ type ConfirmationPageProps = {
onSecondaryButtonPress?: () => void;
shouldShowSecondaryButton?: boolean;
+
+ /** Whether the secondary confirmation button should be disabled */
+ isSecondaryButtonDisabled?: boolean;
+
+ /** Whether the secondary confirmation button should show a loading spinner */
+ isSecondaryButtonLoading?: boolean;
headingStyle?: TextStyle;
/** Additional style for the animation */
@@ -85,6 +91,8 @@ function ConfirmationPage({
secondaryButtonText = '',
onSecondaryButtonPress = () => {},
shouldShowSecondaryButton = false,
+ isSecondaryButtonDisabled = false,
+ isSecondaryButtonLoading = false,
headingStyle,
illustrationStyle,
descriptionStyle,
@@ -155,6 +163,8 @@ function ConfirmationPage({
size={CONST.BUTTON_SIZE.LARGE}
testID="confirmation-secondary-button"
style={styles.mt3}
+ isDisabled={isSecondaryButtonDisabled}
+ isLoading={isSecondaryButtonLoading}
onPress={onSecondaryButtonPress}
>
{secondaryButtonText}
diff --git a/src/components/DraggableList/index.native.tsx b/src/components/DraggableList/index.native.tsx
index 3cf6789230be..e7227f396086 100644
--- a/src/components/DraggableList/index.native.tsx
+++ b/src/components/DraggableList/index.native.tsx
@@ -1,23 +1,318 @@
+import setLegendListItemZIndex from '@components/LegendList/setLegendListItemZIndex';
+
import useThemeStyles from '@hooks/useThemeStyles';
-import type {FlatList} from 'react-native-gesture-handler';
+import type {LegendListRef, LegendListRenderItemProps} from '@legendapp/list/react-native';
+import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent} from 'react-native';
-import React from 'react';
-import DraggableFlatList from 'react-native-draggable-flatlist';
+import {LegendList} from '@legendapp/list/react-native';
+import React, {useEffect, useImperativeHandle, useRef, useState} from 'react';
+import {View} from 'react-native';
+import {Gesture, GestureDetector} from 'react-native-gesture-handler';
+import Animated, {useAnimatedStyle, useSharedValue} from 'react-native-reanimated';
+import {scheduleOnRN} from 'react-native-worklets';
import type DraggableListProps from './types';
+import type {DraggableListRef} from './types';
+
+import {getDragTargetIndex, reorderItems} from './utils';
-function DraggableList({ref, onSelectRow, focusedIndex, isItemDragDisabled, isItemDisabled, ...viewProps}: DraggableListProps & {ref?: React.ForwardedRef>}) {
+const AUTOSCROLL_EDGE_SIZE = 60;
+const AUTOSCROLL_STEP = 20;
+const ACTIVE_ITEM_Z_INDEX = 999;
+type SharedNumber = ReturnType>;
+type DraggableRowProps = {
+ activeIndex: SharedNumber;
+ children: React.ReactNode;
+ disabled: boolean;
+ index: number;
+ onDragCancel: (index: number) => void;
+ onDragEnd: (index: number, translationY: number) => void;
+ onDragUpdate: (index: number, translationY: number) => void;
+ onLayout: (index: number, event: LayoutChangeEvent) => void;
+ translationY: SharedNumber;
+};
+function DraggableRow({activeIndex, children, disabled, index, onDragCancel, onDragEnd, onDragUpdate, onLayout, translationY}: DraggableRowProps) {
+ const gesture = Gesture.Pan()
+ .enabled(!disabled)
+ .manualActivation(true)
+ .onTouchesMove((_event, stateManager) => {
+ if (activeIndex.get() !== index) {
+ return;
+ }
+ stateManager.activate();
+ })
+ .onUpdate((event) => scheduleOnRN(onDragUpdate, index, event.translationY))
+ .onEnd((event, success) => {
+ if (!success) {
+ return;
+ }
+ scheduleOnRN(onDragEnd, index, event.translationY);
+ })
+ .onFinalize(() => scheduleOnRN(onDragCancel, index))
+ .withTestId(`draggable-list-row-${index}`);
+ const animatedStyle = useAnimatedStyle(() => {
+ if (activeIndex.get() !== index) {
+ return {
+ zIndex: 0,
+ };
+ }
+ return {
+ transform: [
+ {
+ translateY: translationY.get(),
+ },
+ ],
+ zIndex: 1,
+ };
+ });
+ return (
+
+ onLayout(index, event)}
+ style={animatedStyle}
+ testID={`draggable-list-row-layout-${index}`}
+ >
+ {children}
+
+
+ );
+}
+function DraggableList({
+ ref,
+ data,
+ renderItem,
+ keyExtractor,
+ onDragEnd,
+ isItemDragDisabled,
+ ListFooterComponent,
+ disableScroll = false,
+}: DraggableListProps & {
+ ref?: React.Ref;
+}) {
const styles = useThemeStyles();
+ const listRef = useRef(null);
+ const measuredItemSizesRef = useRef>([]);
+ const targetIndexRef = useRef(null);
+ const scrollOffsetRef = useRef(0);
+ const initialScrollOffsetRef = useRef(0);
+ const viewportSizeRef = useRef(0);
+ const contentSizeRef = useRef(0);
+ const activeItemIndexRef = useRef(null);
+ const lastGestureTranslationRef = useRef(0);
+ const autoscrollFrameRef = useRef(null);
+ const [activeItemIndex, setActiveItemIndex] = useState(null);
+ const activeIndex = useSharedValue(-1);
+ const translationY = useSharedValue(0);
+ const listExtraData = {
+ activeItemIndex,
+ isItemDragDisabled,
+ renderItem,
+ };
+ useImperativeHandle(ref, () => ({
+ scrollToEnd: (options) => {
+ listRef.current?.scrollToEnd(options);
+ },
+ }));
+ useEffect(() => {
+ measuredItemSizesRef.current = [];
+ }, [data]);
+ useEffect(
+ () => () => {
+ if (autoscrollFrameRef.current === null) {
+ return;
+ }
+ cancelAnimationFrame(autoscrollFrameRef.current);
+ },
+ [],
+ );
+ const stopAutoscroll = () => {
+ if (autoscrollFrameRef.current === null) {
+ return;
+ }
+ cancelAnimationFrame(autoscrollFrameRef.current);
+ autoscrollFrameRef.current = null;
+ };
+ const getItemLayouts = () => {
+ const listState = listRef.current?.getState();
+ let fallbackOffset = 0;
+ return data.map((_item, index) => {
+ const measuredSize = measuredItemSizesRef.current.at(index);
+ const stateSize = listState?.sizeAtIndex?.(index);
+ const size = stateSize && stateSize > 0 ? stateSize : (measuredSize ?? 0);
+ const stateOffset = listState?.positionAtIndex?.(index);
+ const offset = typeof stateOffset === 'number' && Number.isFinite(stateOffset) ? stateOffset : fallbackOffset;
+ fallbackOffset = offset + size;
+ return size > 0
+ ? {
+ offset,
+ size,
+ }
+ : undefined;
+ });
+ };
+ const startDrag = (index: number) => {
+ if (activeItemIndexRef.current !== null) {
+ return;
+ }
+ const item = data.at(index);
+ if (item === undefined || (isItemDragDisabled?.(item) ?? false)) {
+ return;
+ }
+ initialScrollOffsetRef.current = scrollOffsetRef.current;
+ targetIndexRef.current = index;
+ activeItemIndexRef.current = index;
+ lastGestureTranslationRef.current = 0;
+ activeIndex.set(index);
+ translationY.set(0);
+ setLegendListItemZIndex(listRef.current, index, ACTIVE_ITEM_Z_INDEX);
+ setActiveItemIndex(index);
+ };
+ const updateDragPosition = (index: number, gestureTranslationY: number) => {
+ if (activeItemIndexRef.current !== index) {
+ return;
+ }
+ const scrollDelta = scrollOffsetRef.current - initialScrollOffsetRef.current;
+ translationY.set(gestureTranslationY + scrollDelta);
+ const itemLayouts = getItemLayouts();
+ targetIndexRef.current = getDragTargetIndex(itemLayouts, index, gestureTranslationY + scrollDelta);
+ if (disableScroll) {
+ return;
+ }
+ const activeLayout = itemLayouts.at(index);
+ const listState = listRef.current?.getState();
+ const viewportSize = viewportSizeRef.current > 0 ? viewportSizeRef.current : (listState?.scrollLength ?? 0);
+ const contentSize = contentSizeRef.current > 0 ? contentSizeRef.current : (listState?.contentLength ?? 0);
+ if (!activeLayout || viewportSize <= 0) {
+ return;
+ }
+ const centerInViewport = activeLayout.offset + activeLayout.size / 2 + gestureTranslationY - initialScrollOffsetRef.current;
+ const maxOffset = Math.max(0, contentSize - viewportSize);
+ let nextOffset = scrollOffsetRef.current;
+ if (centerInViewport < AUTOSCROLL_EDGE_SIZE) {
+ nextOffset = Math.max(0, nextOffset - AUTOSCROLL_STEP);
+ } else if (centerInViewport > viewportSize - AUTOSCROLL_EDGE_SIZE) {
+ nextOffset = Math.min(maxOffset, nextOffset + AUTOSCROLL_STEP);
+ }
+ if (nextOffset === scrollOffsetRef.current) {
+ return;
+ }
+ listRef.current?.scrollToOffset({
+ offset: nextOffset,
+ animated: false,
+ });
+ // iOS does not emit intermediate scroll events for every programmatic step, so keep the
+ // feedback loop moving with the offset we just requested.
+ scrollOffsetRef.current = nextOffset;
+ };
+ const runAutoscrollFrame = () => {
+ const index = activeItemIndexRef.current;
+ if (index === null) {
+ autoscrollFrameRef.current = null;
+ return;
+ }
+ updateDragPosition(index, lastGestureTranslationRef.current);
+ autoscrollFrameRef.current = requestAnimationFrame(runAutoscrollFrame);
+ };
+ const updateDrag = (index: number, gestureTranslationY: number) => {
+ lastGestureTranslationRef.current = gestureTranslationY;
+ updateDragPosition(index, gestureTranslationY);
+ if (autoscrollFrameRef.current === null) {
+ autoscrollFrameRef.current = requestAnimationFrame(runAutoscrollFrame);
+ }
+ };
+ const resetDrag = () => {
+ stopAutoscroll();
+ const activeIndexToReset = activeItemIndexRef.current;
+ if (activeIndexToReset !== null) {
+ setLegendListItemZIndex(listRef.current, activeIndexToReset, 0);
+ }
+ activeItemIndexRef.current = null;
+ activeIndex.set(-1);
+ translationY.set(0);
+ targetIndexRef.current = null;
+ setActiveItemIndex(null);
+ };
+ const finishDrag = (index: number, gestureTranslationY: number) => {
+ updateDrag(index, gestureTranslationY);
+ const targetIndex = targetIndexRef.current ?? index;
+ resetDrag();
+ if (targetIndex === index) {
+ return;
+ }
+ onDragEnd?.({
+ data: reorderItems(data, index, targetIndex),
+ });
+ };
+ const cancelDrag = (index: number) => {
+ if (activeItemIndexRef.current !== index) {
+ return;
+ }
+ resetDrag();
+ };
+ const recordItemLayout = (index: number, event: LayoutChangeEvent) => {
+ measuredItemSizesRef.current[index] = event.nativeEvent.layout.height;
+ };
+ const handleScroll = (event: NativeSyntheticEvent) => {
+ scrollOffsetRef.current = event.nativeEvent.contentOffset.y;
+ viewportSizeRef.current = event.nativeEvent.layoutMeasurement.height;
+ contentSizeRef.current = event.nativeEvent.contentSize.height;
+ };
+ const handleListLayout = (event: LayoutChangeEvent) => {
+ viewportSizeRef.current = event.nativeEvent.layout.height;
+ };
+ const handleContentSizeChange = (_width: number, height: number) => {
+ contentSizeRef.current = height;
+ };
+ const renderRow = ({item, index}: LegendListRenderItemProps) => {
+ const content = renderItem({
+ item,
+ getIndex: () => index,
+ isActive: activeItemIndex === index,
+ drag: () => startDrag(index),
+ });
+ return (
+
+ {content}
+
+ );
+ };
return (
-
+
+
+
);
}
-
export default DraggableList;
diff --git a/src/components/DraggableList/index.tsx b/src/components/DraggableList/index.tsx
index 73545c26ba2f..eab036ab6558 100644
--- a/src/components/DraggableList/index.tsx
+++ b/src/components/DraggableList/index.tsx
@@ -14,9 +14,10 @@ import type {ScrollView as RNScrollView} from 'react-native';
import {closestCenter, DndContext, KeyboardSensor, PointerSensor, useSensor, useSensors} from '@dnd-kit/core';
import {restrictToParentElement, restrictToVerticalAxis} from '@dnd-kit/modifiers';
import {arrayMove, SortableContext, sortableKeyboardCoordinates, verticalListSortingStrategy} from '@dnd-kit/sortable';
-import React, {useEffect, useId, useRef} from 'react';
+import React, {useEffect, useId, useImperativeHandle, useRef} from 'react';
import type DraggableListProps from './types';
+import type {DraggableListRef} from './types';
import SortableItem from './SortableItem';
@@ -42,8 +43,13 @@ function DraggableList({
disableScroll,
focusedIndex: controlledFocusedIndex,
ref,
-}: DraggableListProps & {ref?: React.ForwardedRef}) {
+}: DraggableListProps & {ref?: React.Ref}) {
const styles = useThemeStyles();
+ const scrollViewRef = useRef(null);
+
+ useImperativeHandle(ref, () => ({
+ scrollToEnd: (options) => scrollViewRef.current?.scrollToEnd(options),
+ }));
const isControlled = controlledFocusedIndex !== undefined;
const hasKeyboardNav = !isControlled && !!onSelectRow;
const containerRef = useRef(null);
@@ -180,7 +186,7 @@ function DraggableList({
return (
diff --git a/src/components/DraggableList/types.ts b/src/components/DraggableList/types.ts
index 55fcfd9dee8c..e3e41cd3dc9f 100644
--- a/src/components/DraggableList/types.ts
+++ b/src/components/DraggableList/types.ts
@@ -1,7 +1,10 @@
import type React from 'react';
-import type {RenderItemParams} from 'react-native-draggable-flatlist';
-type DraggableListRenderItemParams = RenderItemParams & {
+type DraggableListRenderItemParams = {
+ item: T;
+ drag: () => void;
+ getIndex: () => number | undefined;
+ isActive: boolean;
isFocused?: boolean;
};
@@ -9,10 +12,11 @@ type DraggableListData = {
data: T[];
};
-/**
- * Props for DraggableList are inspired by the `react-native-draggable-flatlist` library.
- * https://github.com/computerjazz/react-native-draggable-flatlist#props
- */
+type DraggableListRef = {
+ scrollToEnd: (options?: {animated?: boolean}) => void;
+};
+
+/** Props shared by the native LegendList drag implementation and the web sortable list. */
type DraggableListProps = {
/** Unique key for each item (required) */
keyExtractor: (item: T, index: number) => string;
@@ -50,4 +54,4 @@ type SortableItemProps = {
};
export default DraggableListProps;
-export type {SortableItemProps};
+export type {DraggableListRef, DraggableListRenderItemParams, SortableItemProps};
diff --git a/src/components/DraggableList/utils.ts b/src/components/DraggableList/utils.ts
new file mode 100644
index 000000000000..9e79282ff2be
--- /dev/null
+++ b/src/components/DraggableList/utils.ts
@@ -0,0 +1,38 @@
+type ItemLayout = {
+ offset: number;
+ size: number;
+};
+
+function reorderItems(items: T[], fromIndex: number, toIndex: number): T[] {
+ const reorderedItems = [...items];
+ const [movedItem] = reorderedItems.splice(fromIndex, 1);
+ if (movedItem === undefined) {
+ return items;
+ }
+ reorderedItems.splice(toIndex, 0, movedItem);
+ return reorderedItems;
+}
+
+function getDragTargetIndex(itemLayouts: Array, activeIndex: number, translationY: number): number {
+ const activeLayout = itemLayouts.at(activeIndex);
+ if (!activeLayout) {
+ return activeIndex;
+ }
+ const activeCenter = activeLayout.offset + activeLayout.size / 2 + translationY;
+ let targetIndex = activeIndex;
+ let nearestDistance = Number.POSITIVE_INFINITY;
+ for (const [index, layout] of itemLayouts.entries()) {
+ if (!layout) {
+ continue;
+ }
+ const distance = Math.abs(layout.offset + layout.size / 2 - activeCenter);
+ if (distance >= nearestDistance) {
+ continue;
+ }
+ nearestDistance = distance;
+ targetIndex = index;
+ }
+ return targetIndex;
+}
+
+export {getDragTargetIndex, reorderItems};
diff --git a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx
index 59f8dab0210e..3eadb9ad3ec2 100644
--- a/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx
+++ b/src/components/EmojiPicker/EmojiPickerMenu/BaseEmojiPickerMenu.tsx
@@ -10,12 +10,12 @@ import type {EmojiPickerList, EmojiPickerListItem, HeaderIndices} from '@libs/Em
import CONST from '@src/CONST';
-import type {FlashListRef, ListRenderItem} from '@shopify/flash-list';
+import type {LegendListProps, LegendListRef} from '@legendapp/list/react-native';
import type {ForwardedRef} from 'react';
import type {StyleProp, ViewStyle} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
-import {FlashList} from '@shopify/flash-list';
+import {LegendList} from '@legendapp/list/react-native';
import React from 'react';
import {View} from 'react-native';
@@ -33,7 +33,7 @@ type BaseEmojiPickerMenuProps = {
listWrapperStyle?: StyleProp;
data: EmojiPickerList;
- renderItem: ListRenderItem;
+ renderItem: NonNullable['renderItem']>;
extraData?: Array | ((skinTone: number) => void)>;
stickyHeaderIndices?: number[];
alwaysBounceVertical?: boolean;
@@ -42,11 +42,11 @@ type BaseEmojiPickerMenuProps = {
/** The current search input value, used for accessibility re-announcements */
searchValue?: string;
- ref?: ForwardedRef>;
+ ref?: ForwardedRef;
};
/**
- * Improves FlashList's recycling when there are different types of items
+ * Improves LegendList's recycling when there are different types of items
*/
const getItemType = (item: EmojiPickerListItem): string | undefined => {
// item is undefined only when list is empty
@@ -116,7 +116,8 @@ function BaseEmojiPickerMenu({
/>
)}
- }
alwaysBounceVertical={alwaysBounceVertical}
contentContainerStyle={styles.ph4}
- extraData={extraData}
+ extraData={[extraData, renderItem]}
getItemType={getItemType}
onMomentumScrollEnd={onMomentumScrollEnd}
- overrideProps={{
- // scrollPaddingTop set to consider sticky header while scrolling, https://github.com/Expensify/App/issues/36883
- style: {
- minHeight: 1,
- minWidth: 1,
- scrollPaddingTop: isFiltered ? 0 : CONST.EMOJI_PICKER_ITEM_HEIGHT,
- },
+ style={{
+ minHeight: 1,
+ minWidth: 1,
+ // Keep keyboard scrolling below the sticky category header.
+ scrollPaddingTop: isFiltered ? 0 : CONST.EMOJI_PICKER_ITEM_HEIGHT,
}}
scrollEnabled={data.length > 0}
/>
diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx
index 40eee6a9aaea..c4f2a795fbe1 100644
--- a/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx
+++ b/src/components/EmojiPicker/EmojiPickerMenu/index.native.tsx
@@ -19,7 +19,7 @@ import {getRemovedSkinToneEmoji} from '@libs/EmojiUtils';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
-import type {ListRenderItem} from '@shopify/flash-list';
+import type {LegendListProps} from '@legendapp/list/react-native';
import lodashDebounce from 'lodash/debounce';
import React, {useCallback, useMemo, useRef, useState} from 'react';
@@ -145,8 +145,8 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro
* Items with the code "SPACER" return nothing and are used to fill rows up to 8
* so that the sticky headers function properly.
*/
- const renderItem: ListRenderItem = useCallback(
- ({item, target, index}) => {
+ const renderItem: NonNullable['renderItem']> = useCallback(
+ ({item, index}) => {
const code = item.code;
const types = 'types' in item ? item.types : undefined;
@@ -161,7 +161,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro
accessible
accessibilityRole="header"
accessibilityLabel={translate(`emojiPicker.headers.${code}` as TranslationPaths)}
- style={[styles.emojiHeaderContainer, target === 'StickyHeader' ? styles.mh4 : {width: windowWidth}]}
+ style={[styles.emojiHeaderContainer, {width: windowWidth}]}
onLayout={() => handleHeaderLayout(index)}
>
{translate(`emojiPicker.headers.${code}` as TranslationPaths)}
diff --git a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx
index 49fb15ae2b3f..4568f08c3846 100755
--- a/src/components/EmojiPicker/EmojiPickerMenu/index.tsx
+++ b/src/components/EmojiPicker/EmojiPickerMenu/index.tsx
@@ -24,7 +24,7 @@ import {shouldAutoFocusOnKeyPress} from '@libs/ReportUtils';
import CONST from '@src/CONST';
import type {TranslationPaths} from '@src/languages/types';
-import type {ListRenderItem} from '@shopify/flash-list';
+import type {LegendListProps} from '@legendapp/list/react-native';
import throttle from 'lodash/throttle';
import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
@@ -320,8 +320,8 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro
* so that the sticky headers function properly.
*
*/
- const renderItem: ListRenderItem = useCallback(
- ({item, index, target}) => {
+ const renderItem: NonNullable['renderItem']> = useCallback(
+ ({item, index}) => {
const code = item.code;
const types = 'types' in item ? item.types : undefined;
@@ -336,11 +336,7 @@ function EmojiPickerMenu({onEmojiSelected, activeEmoji, ref}: EmojiPickerMenuPro
tabIndex={-1}
role={CONST.ROLE.HEADING}
onLayout={() => handleHeaderLayout(index)}
- style={[
- styles.emojiHeaderContainer,
- styles.emojiHeaderContainerWidth(shouldUseNarrowLayout, windowWidth),
- target === 'StickyHeader' ? styles.stickyHeaderEmoji : undefined,
- ]}
+ style={[styles.emojiHeaderContainer, styles.emojiHeaderContainerWidth(shouldUseNarrowLayout, windowWidth)]}
>
{translate(`emojiPicker.headers.${code}` as TranslationPaths)}
diff --git a/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.ts b/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.ts
index cb3c2901ca9a..9047783abd1f 100644
--- a/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.ts
+++ b/src/components/EmojiPicker/EmojiPickerMenu/useEmojiPickerMenu.ts
@@ -8,25 +8,25 @@ import useSafeAreaInsets from '@hooks/useSafeAreaInsets';
import useStyleUtils from '@hooks/useStyleUtils';
import useWindowDimensions from '@hooks/useWindowDimensions';
-import type {EmojiPickerList, EmojiPickerListItem} from '@libs/EmojiUtils';
+import type {EmojiPickerList} from '@libs/EmojiUtils';
import {getHeaderEmojis, getSpacersIndexes, mergeEmojisWithFrequentlyUsedEmojis, processFrequentlyUsedEmojis, suggestEmojis} from '@libs/EmojiUtils';
import isInLandscapeModeUtil from '@libs/isInLandscapeMode';
import ONYXKEYS from '@src/ONYXKEYS';
import calculateModalHeightInLandscapeMode from '@src/utils/calculateModalHeightInLandscapeMode';
-import type {FlashListRef} from '@shopify/flash-list';
+import type {LegendListRef} from '@legendapp/list/react-native';
-import {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import {useEffect, useRef, useState} from 'react';
const useEmojiPickerMenu = () => {
- const emojiListRef = useRef>(null);
+ const emojiListRef = useRef(null);
const [frequentlyUsedEmojis] = useOnyx(ONYXKEYS.FREQUENTLY_USED_EMOJIS);
- const allEmojis = useMemo(() => mergeEmojisWithFrequentlyUsedEmojis(emojis, processFrequentlyUsedEmojis(frequentlyUsedEmojis)), [frequentlyUsedEmojis]);
- const headerEmojis = useMemo(() => getHeaderEmojis(allEmojis), [allEmojis]);
- const headerRowIndices = useMemo(() => headerEmojis.map((headerEmoji) => headerEmoji.index), [headerEmojis]);
- const spacersIndexes = useMemo(() => getSpacersIndexes(allEmojis), [allEmojis]);
+ const allEmojis = mergeEmojisWithFrequentlyUsedEmojis(emojis, processFrequentlyUsedEmojis(frequentlyUsedEmojis));
+ const headerEmojis = getHeaderEmojis(allEmojis);
+ const headerRowIndices = headerEmojis.map((headerEmoji) => headerEmoji.index);
+ const spacersIndexes = getSpacersIndexes(allEmojis);
const [filteredEmojis, setFilteredEmojis] = useState(allEmojis);
const [headerIndices, setHeaderIndices] = useState(headerRowIndices);
const isListFiltered = allEmojis.length !== filteredEmojis.length;
@@ -60,15 +60,12 @@ const useEmojiPickerMenu = () => {
/**
* Suggest emojis based on the search term
*/
- const suggestEmojisCallback = useCallback(
- (searchTerm: string) => {
- const normalizedSearchTerm = searchTerm.toLowerCase().trim().replaceAll(':', '');
- const emojisSuggestions = suggestEmojis(`:${normalizedSearchTerm}`, preferredLocale, allEmojis.length);
+ const suggestEmojisCallback = (searchTerm: string) => {
+ const normalizedSearchTerm = searchTerm.toLowerCase().trim().replaceAll(':', '');
+ const emojisSuggestions = suggestEmojis(`:${normalizedSearchTerm}`, preferredLocale, allEmojis.length);
- return [normalizedSearchTerm, emojisSuggestions] as const;
- },
- [allEmojis.length, preferredLocale],
- );
+ return [normalizedSearchTerm, emojisSuggestions] as const;
+ };
return {
allEmojis,
diff --git a/src/components/EnableGlobalReimbursementsPayModal.tsx b/src/components/EnableGlobalReimbursementsPayModal.tsx
new file mode 100644
index 000000000000..ddb2fea19793
--- /dev/null
+++ b/src/components/EnableGlobalReimbursementsPayModal.tsx
@@ -0,0 +1,74 @@
+import useConfirmModal from '@hooks/useConfirmModal';
+import useLocalize from '@hooks/useLocalize';
+import useOnyx from '@hooks/useOnyx';
+
+import {getEnableGlobalReimbursementsBusinessNavigationRoute} from '@libs/Navigation/helpers/enableGlobalReimbursementsNavigationUtils';
+import Navigation from '@libs/Navigation/Navigation';
+
+import {clearCorpayPayModal} from '@userActions/App';
+
+import CONST from '@src/CONST';
+import ONYXKEYS from '@src/ONYXKEYS';
+import type CorpayPayModal from '@src/types/onyx/CorpayPayModal';
+
+import {useEffect, useEffectEvent, useRef} from 'react';
+
+import {useLockedAccountActions, useLockedAccountState} from './LockedAccountModalProvider';
+import {ModalActions} from './Modal/Global/ModalContext';
+
+function EnableGlobalReimbursementsPayModal() {
+ const {translate} = useLocalize();
+ const [corpayPayModal] = useOnyx(ONYXKEYS.RAM_ONLY_CORPAY_PAY_MODAL);
+ const {showConfirmModal} = useConfirmModal();
+ const {isAccountLocked} = useLockedAccountState();
+ const {showLockedAccountModal} = useLockedAccountActions();
+ const isModalOpenRef = useRef(false);
+
+ const showCorpayPayModal = useEffectEvent(async (modalData: CorpayPayModal) => {
+ if (isModalOpenRef.current) {
+ return;
+ }
+ isModalOpenRef.current = true;
+ const navigationPathAtSignal = Navigation.getActiveRoute();
+ const result = await showConfirmModal({
+ id: 'corpayPayModal',
+ title: translate('common.corpayPayModalTitle'),
+ prompt: translate('common.corpayPayModalPrompt'),
+ confirmText: translate('common.enableGlobalReimbursements'),
+ cancelText: translate('common.cancel'),
+ shouldShowCancelButton: true,
+ });
+ isModalOpenRef.current = false;
+ if (result.action === ModalActions.CONFIRM) {
+ if (isAccountLocked) {
+ showLockedAccountModal();
+ } else {
+ const {bankAccountID, bankCountry, bankCurrency} = modalData;
+ Navigation.navigate(
+ getEnableGlobalReimbursementsBusinessNavigationRoute(
+ bankAccountID,
+ CONST.ENABLE_GLOBAL_REIMBURSEMENTS.PAGE_NAME.BUSINESS_INFO.REGISTRATION_NUMBER,
+ {
+ bankCountry,
+ bankCurrency,
+ },
+ navigationPathAtSignal,
+ ),
+ {skipMatchingFullScreenRoute: true},
+ );
+ }
+ }
+ clearCorpayPayModal();
+ });
+
+ useEffect(() => {
+ if (!corpayPayModal) {
+ return;
+ }
+ showCorpayPayModal(corpayPayModal);
+ }, [corpayPayModal]);
+
+ return null;
+}
+
+export default EnableGlobalReimbursementsPayModal;
diff --git a/src/components/FeatureTraining/FeatureTrainingCarousel.tsx b/src/components/FeatureTraining/FeatureTrainingCarousel.tsx
index efb4849265de..d3a917be8690 100644
--- a/src/components/FeatureTraining/FeatureTrainingCarousel.tsx
+++ b/src/components/FeatureTraining/FeatureTrainingCarousel.tsx
@@ -2,11 +2,13 @@ import useThemeStyles from '@hooks/useThemeStyles';
import variables from '@styles/variables';
+import type {LegendListRef, ViewToken} from '@legendapp/list/react-native';
import type {ReactElement, ReactNode} from 'react';
-import type {LayoutChangeEvent, FlatList as RNFlatList, ViewabilityConfig, ViewStyle, ViewToken} from 'react-native';
+import type {LayoutChangeEvent, ViewStyle} from 'react-native';
-import React, {cloneElement, isValidElement, useCallback, useEffect, useMemo, useRef, useState} from 'react';
-import {FlatList, Platform, View} from 'react-native';
+import {LegendList} from '@legendapp/list/react-native';
+import React, {cloneElement, isValidElement, useEffect, useRef, useState} from 'react';
+import {Platform, View} from 'react-native';
import type {FeatureTrainingActionsValue, FeatureTrainingStateValue} from './context';
import type {IllustrationProps} from './primitives/Illustration';
@@ -21,15 +23,20 @@ import Illustration from './primitives/Illustration';
import Page from './primitives/Page';
import PaginationDots from './primitives/PaginationDots';
-const CAROUSEL_VIEWABILITY_CONFIG: ViewabilityConfig = {itemVisiblePercentThreshold: 95};
-
-const WEB_CAROUSEL_PAGE_SNAP_STYLE: ViewStyle = Platform.OS === 'web' ? ({scrollSnapAlign: 'start', scrollSnapStop: 'always'} as ViewStyle) : {};
-
+const CAROUSEL_VIEWABILITY_CONFIG = {
+ itemVisiblePercentThreshold: 95,
+};
+const WEB_CAROUSEL_PAGE_SNAP_STYLE: ViewStyle =
+ Platform.OS === 'web'
+ ? ({
+ scrollSnapAlign: 'start',
+ scrollSnapStop: 'always',
+ } as ViewStyle)
+ : {};
type BodyElement = ReactElement>;
type BodyTextElement = ReactElement>;
type IllustrationElement = ReactElement;
type PageElement = ReactElement>;
-
function isBodyElement(child: ReactElement): child is BodyElement {
return child.type === Body;
}
@@ -42,13 +49,11 @@ function isIllustrationElement(child: ReactElement): child is IllustrationElemen
function isPageElement(child: ReactElement): child is PageElement {
return child.type === Page;
}
-
type SplitPage = {
illustration: IllustrationElement | null;
body: BodyElement | null;
bodyText: BodyTextElement | null;
};
-
function splitPageChildren(pageChildren: ReactNode): SplitPage {
let illustration: IllustrationElement | null = null;
let body: BodyElement | null = null;
@@ -71,9 +76,12 @@ function splitPageChildren(pageChildren: ReactNode): SplitPage {
});
}
});
- return {illustration, body, bodyText};
+ return {
+ illustration,
+ body,
+ bodyText,
+ };
}
-
function FeatureTrainingCarousel({
onConfirm,
onClose,
@@ -85,15 +93,21 @@ function FeatureTrainingCarousel({
}: FeatureTrainingCarouselProps) {
const [currentPage, setCurrentPage] = useState(0);
const [carouselViewportWidth, setCarouselViewportWidth] = useState(0);
- const horizontalListRef = useRef>(null);
+ const horizontalListRef = useRef(null);
const lastReportedPage = useRef(0);
-
const [contentMinHeight, setContentMinHeight] = useState(undefined);
const measuredHeightsRef = useRef>({});
-
- const {Wrapper, wrapperProps, setContainerHeight, shouldUseScrollView: usingScrollView, isInLandscapeMode} = useScrollableWrapper({shouldUseScrollView, width});
-
- const pages = useMemo(() => {
+ const {
+ Wrapper,
+ wrapperProps,
+ setContainerHeight,
+ shouldUseScrollView: usingScrollView,
+ isInLandscapeMode,
+ } = useScrollableWrapper({
+ shouldUseScrollView,
+ width,
+ });
+ const pages = (() => {
const pageList: SplitPage[] = [];
React.Children.forEach(children, (child) => {
if (!isValidElement(child) || !isPageElement(child)) {
@@ -102,14 +116,12 @@ function FeatureTrainingCarousel({
pageList.push(splitPageChildren(child.props.children));
});
return pageList;
- }, [children]);
-
+ })();
const onPageChangeRef = useRef(onPageChange);
useEffect(() => {
onPageChangeRef.current = onPageChange;
}, [onPageChange]);
-
- const onViewableItemsChanged = useCallback(({viewableItems}: {viewableItems: ViewToken[]}) => {
+ const onViewableItemsChanged = ({viewableItems}: {viewableItems: ViewToken[]}) => {
const entry = viewableItems.at(0);
if (entry?.index == null || entry.index === lastReportedPage.current) {
return;
@@ -117,19 +129,22 @@ function FeatureTrainingCarousel({
lastReportedPage.current = entry.index;
setCurrentPage(entry.index);
onPageChangeRef.current?.(entry.index);
- }, []);
-
- const advance = useCallback(() => {
- horizontalListRef.current?.scrollToIndex({index: Math.min(currentPage + 1, pages.length - 1), animated: true});
- }, [currentPage, pages.length]);
-
- const goBack = useCallback(() => {
+ };
+ const advance = () => {
+ horizontalListRef.current?.scrollToIndex({
+ index: Math.min(currentPage + 1, pages.length - 1),
+ animated: true,
+ });
+ };
+ const goBack = () => {
if (currentPage <= 0) {
return;
}
- horizontalListRef.current?.scrollToIndex({index: Math.max(currentPage - 1, 0), animated: true});
- }, [currentPage]);
-
+ horizontalListRef.current?.scrollToIndex({
+ index: Math.max(currentPage - 1, 0),
+ animated: true,
+ });
+ };
const pageCountRef = useRef(pages.length);
useEffect(() => {
pageCountRef.current = pages.length;
@@ -142,10 +157,12 @@ function FeatureTrainingCarousel({
if (carouselViewportWidth <= 0) {
return;
}
- horizontalListRef.current?.scrollToOffset({offset: lastReportedPage.current * carouselViewportWidth, animated: false});
+ horizontalListRef.current?.scrollToOffset({
+ offset: lastReportedPage.current * carouselViewportWidth,
+ animated: false,
+ });
}, [carouselViewportWidth]);
-
- const recordPageHeight = useCallback((index: number, measured: number) => {
+ const recordPageHeight = (index: number, measured: number) => {
if (measuredHeightsRef.current[index] === measured) {
return;
}
@@ -154,60 +171,52 @@ function FeatureTrainingCarousel({
return;
}
setContentMinHeight(Math.max(...Object.values(measuredHeightsRef.current)));
- }, []);
-
+ };
const isLastPage = pages.length === 0 || currentPage >= pages.length - 1;
-
- const handleConfirm = useCallback(() => {
+ const handleConfirm = () => {
onConfirm?.(false);
- }, [onConfirm]);
-
- const handleClose = useCallback(() => onClose?.(), [onClose]);
-
- const stateValue = useMemo(
- () => ({
- willShowAgain: true,
- shouldShowLoadingImmediatelyOnPress: isLastPage ? undefined : false,
- isCarousel: true,
- confirmSentryLabel,
- currentPage,
- pageCount: pages.length,
- isLastPage,
- contentMinHeight,
- }),
- [isLastPage, confirmSentryLabel, currentPage, pages.length, contentMinHeight],
- );
-
- const actionsValue = useMemo(
- () => ({
- toggleWillShowAgain: () => {},
- handleConfirm,
- handleClose,
- advance,
- goBack,
- }),
- [handleConfirm, handleClose, advance, goBack],
- );
-
+ };
+ const handleClose = () => onClose?.();
+ const stateValue: FeatureTrainingStateValue = {
+ willShowAgain: true,
+ shouldShowLoadingImmediatelyOnPress: isLastPage ? undefined : false,
+ isCarousel: true,
+ confirmSentryLabel,
+ currentPage,
+ pageCount: pages.length,
+ isLastPage,
+ contentMinHeight,
+ };
+ const actionsValue: FeatureTrainingActionsValue = {
+ toggleWillShowAgain: () => {},
+ handleConfirm,
+ handleClose,
+ advance,
+ goBack,
+ };
const currentPageBody = pages.at(currentPage)?.body ?? null;
-
- const onWrapperLayout = useCallback(
- (e: LayoutChangeEvent) => {
- const newWidth = e.nativeEvent.layout.width;
- if (newWidth === carouselViewportWidth || newWidth <= 0) {
- return;
- }
- setCarouselViewportWidth(newWidth);
- if (!usingScrollView) {
- return;
- }
- setContainerHeight(e.nativeEvent.layout.height);
- },
- [carouselViewportWidth, usingScrollView, setContainerHeight],
- );
-
- const probeStyle = useMemo(() => ({position: 'absolute', left: 0, top: 0, width: carouselViewportWidth, opacity: 0}), [carouselViewportWidth]);
-
+ const carouselExtraData = {
+ currentPage,
+ carouselViewportWidth,
+ };
+ const onWrapperLayout = (e: LayoutChangeEvent) => {
+ const newWidth = e.nativeEvent.layout.width;
+ if (newWidth === carouselViewportWidth || newWidth <= 0) {
+ return;
+ }
+ setCarouselViewportWidth(newWidth);
+ if (!usingScrollView) {
+ return;
+ }
+ setContainerHeight(e.nativeEvent.layout.height);
+ };
+ const probeStyle: ViewStyle = {
+ position: 'absolute',
+ left: 0,
+ top: 0,
+ width: carouselViewportWidth,
+ opacity: 0,
+ };
return (
@@ -240,9 +249,10 @@ function FeatureTrainingCarousel({
{carouselViewportWidth > 0 && (
<>
- `FeatureTrainingCarousel-page-${index}`}
horizontal
pagingEnabled
@@ -255,10 +265,21 @@ function FeatureTrainingCarousel({
keyboardShouldPersistTaps="handled"
viewabilityConfig={CAROUSEL_VIEWABILITY_CONFIG}
onViewableItemsChanged={onViewableItemsChanged}
- getItemLayout={(_data, index) => ({length: carouselViewportWidth, offset: index * carouselViewportWidth, index})}
+ getFixedItemSize={() => carouselViewportWidth}
renderItem={({item, index}) => (
-
- {item.illustration == null ? null : cloneElement(item.illustration, {isFocused: index === currentPage})}
+
+ {item.illustration == null
+ ? null
+ : cloneElement(item.illustration, {
+ isFocused: index === currentPage,
+ })}
)}
/>
@@ -273,9 +294,7 @@ function FeatureTrainingCarousel({
);
}
-
FeatureTrainingCarousel.displayName = 'FeatureTraining.Carousel';
-
type ProbePageProps = {
index: number;
bodyText: BodyTextElement;
@@ -287,9 +306,13 @@ type ProbePageProps = {
// Body's horizontal margins so the probe text wraps at the same width as the visible page.
function ProbePage({index, bodyText, onMeasure}: ProbePageProps) {
const styles = useThemeStyles();
- const onLayout = useCallback((event: LayoutChangeEvent) => onMeasure(index, event.nativeEvent.layout.height), [index, onMeasure]);
-
- return {cloneElement(bodyText, {onLayout})};
+ const onLayout = (event: LayoutChangeEvent) => onMeasure(index, event.nativeEvent.layout.height);
+ return (
+
+ {cloneElement(bodyText, {
+ onLayout,
+ })}
+
+ );
}
-
export default FeatureTrainingCarousel;
diff --git a/src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx b/src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx
deleted file mode 100644
index bc11ccf61296..000000000000
--- a/src/components/FlashList/InvertedFlashList/CellRendererComponent.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import type {StyleProp, ViewProps, ViewStyle} from 'react-native';
-
-import React from 'react';
-import {View} from 'react-native';
-
-type CellRendererComponentProps = ViewProps & {
- index: number;
- style?: StyleProp;
-};
-
-function CellRendererComponent(props: CellRendererComponentProps) {
- return (
-
- );
-}
-
-export default CellRendererComponent;
diff --git a/src/components/FlashList/InvertedFlashList/index.tsx b/src/components/FlashList/InvertedFlashList/index.tsx
deleted file mode 100644
index b343f95b8be1..000000000000
--- a/src/components/FlashList/InvertedFlashList/index.tsx
+++ /dev/null
@@ -1,28 +0,0 @@
-import type FlatListRefType from '@components/FlashList/types';
-
-import type {FlashListProps} from '@shopify/flash-list';
-
-import React from 'react';
-
-import FlashList from '..';
-import CellRendererComponent from './CellRendererComponent';
-
-type InvertedFlashListProps = FlashListProps & {
- data: T[];
- keyExtractor: (item: T, index: number) => string;
-
- /** Ref to the underlying list instance. */
- ref: FlatListRefType;
-};
-
-function InvertedFlashList(props: InvertedFlashListProps) {
- return (
-
- {...props}
- inverted
- CellRendererComponent={CellRendererComponent}
- />
- );
-}
-
-export default InvertedFlashList;
diff --git a/src/components/FlashList/index.tsx b/src/components/FlashList/index.tsx
deleted file mode 100644
index e98507c9a3bd..000000000000
--- a/src/components/FlashList/index.tsx
+++ /dev/null
@@ -1,27 +0,0 @@
-import useEmitComposerScrollEvents from '@hooks/useEmitComposerScrollEvents';
-
-import type {FlashListProps} from '@shopify/flash-list';
-import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native';
-
-import {FlashList as ShopifyFlashList} from '@shopify/flash-list';
-import React from 'react';
-
-function FlashList({onScroll: onScrollProp, inverted, ...restProps}: FlashListProps) {
- const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted});
-
- const handleScroll = (e: NativeSyntheticEvent) => {
- onScrollProp?.(e);
- // Emit scroll events so that ActiveHoverable can suppress hover effects during scroll
- emitComposerScrollEvents();
- };
-
- return (
-
- {...restProps}
- inverted={inverted}
- onScroll={handleScroll}
- />
- );
-}
-
-export default FlashList;
diff --git a/src/components/FlashList/types.ts b/src/components/FlashList/types.ts
deleted file mode 100644
index cf7718d3d148..000000000000
--- a/src/components/FlashList/types.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import type {RefObject} from 'react';
-import type {FlatList} from 'react-native';
-
-/** Ref to the underlying list instance attached via `ref={}`. */
-type FlatListRefType = RefObject | null> | null;
-
-export default FlatListRefType;
diff --git a/src/components/FlatList/FlatList/index.android.tsx b/src/components/FlatList/FlatList/index.android.tsx
deleted file mode 100644
index 6651c78ee7d3..000000000000
--- a/src/components/FlatList/FlatList/index.android.tsx
+++ /dev/null
@@ -1,75 +0,0 @@
-import KeyboardDismissibleFlatList from '@components/KeyboardDismissibleFlatList';
-
-import useThemeStyles from '@hooks/useThemeStyles';
-
-import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native';
-
-import {useFocusEffect} from '@react-navigation/native';
-import React, {useCallback, useRef} from 'react';
-import {FlatList} from 'react-native';
-
-import type {CustomFlatListProps} from './types';
-
-// FlatList wrapped with the freeze component will lose its scroll state when frozen (only for Android).
-// CustomFlatList saves the offset and use it for scrollToOffset() when unfrozen.
-function CustomFlatList({ref, enableAnimatedKeyboardDismissal = false, onMomentumScrollEnd, onScroll, shouldHideContent = false, ...props}: CustomFlatListProps) {
- const lastScrollOffsetRef = useRef(0);
- const styles = useThemeStyles();
-
- const onScreenFocus = useCallback(() => {
- if (typeof ref === 'function') {
- return;
- }
- if (!ref?.current || !lastScrollOffsetRef.current) {
- return;
- }
- if (ref.current && lastScrollOffsetRef.current) {
- ref.current.scrollToOffset({offset: lastScrollOffsetRef.current, animated: false});
- }
- }, [ref]);
-
- const handleScrollEnd = useCallback(
- (event: NativeSyntheticEvent) => {
- onMomentumScrollEnd?.(event);
- lastScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
- },
- [onMomentumScrollEnd],
- );
-
- const handleScroll = (event: NativeSyntheticEvent) => {
- lastScrollOffsetRef.current = event.nativeEvent.contentOffset.y;
- onScroll?.(event);
- };
-
- useFocusEffect(
- useCallback(() => {
- onScreenFocus();
- }, [onScreenFocus]),
- );
-
- const contentContainerStyle = [props.contentContainerStyle, shouldHideContent && styles.opacity0];
-
- if (enableAnimatedKeyboardDismissal) {
- return (
-
- );
- }
-
- return (
-
- {...props}
- ref={ref}
- onScroll={handleScroll}
- onMomentumScrollEnd={handleScrollEnd}
- contentContainerStyle={contentContainerStyle}
- />
- );
-}
-
-export default CustomFlatList;
diff --git a/src/components/FlatList/FlatList/index.ios.tsx b/src/components/FlatList/FlatList/index.ios.tsx
deleted file mode 100644
index 5fc8e9b546a7..000000000000
--- a/src/components/FlatList/FlatList/index.ios.tsx
+++ /dev/null
@@ -1,95 +0,0 @@
-import useFlatListHandle from '@components/FlatList/hooks/useFlatListHandle';
-import type {FlatListInnerRefType} from '@components/FlatList/hooks/useFlatListHandle';
-import KeyboardDismissibleFlatList from '@components/KeyboardDismissibleFlatList';
-
-import useEmitComposerScrollEvents from '@hooks/useEmitComposerScrollEvents';
-import useThemeStyles from '@hooks/useThemeStyles';
-
-import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native';
-
-import React, {useCallback, useRef, useState} from 'react';
-import {FlatList} from 'react-native';
-
-import type {CustomFlatListProps} from './types';
-
-// On iOS, we have to unset maintainVisibleContentPosition while the user is scrolling to prevent jumping to the beginning issue
-function CustomFlatList({
- ref,
- maintainVisibleContentPosition: maintainVisibleContentPositionProp,
- shouldDisableVisibleContentPosition,
- enableAnimatedKeyboardDismissal = false,
- onMomentumScrollBegin,
- onMomentumScrollEnd,
- onScroll: onScrollProp,
- shouldHideContent = false,
- ...restProps
-}: CustomFlatListProps) {
- const [isScrolling, setIsScrolling] = useState(false);
- const styles = useThemeStyles();
- const handleScrollBegin = useCallback(
- (event: NativeSyntheticEvent) => {
- onMomentumScrollBegin?.(event);
- setIsScrolling(true);
- },
- [onMomentumScrollBegin],
- );
-
- const handleScrollEnd = useCallback(
- (event: NativeSyntheticEvent) => {
- onMomentumScrollEnd?.(event);
- setIsScrolling(false);
- },
- [onMomentumScrollEnd],
- );
-
- const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !enableAnimatedKeyboardDismissal, inverted: restProps.inverted});
- const handleScroll = useCallback(
- (e: NativeSyntheticEvent) => {
- onScrollProp?.(e);
- emitComposerScrollEvents();
- },
- [emitComposerScrollEvents, onScrollProp],
- );
-
- const listRef = useRef | null>(null);
- useFlatListHandle({
- ref,
- listRef,
- remainingItemsToDisplay: 0,
- setCurrentDataId: () => {},
- onScrollToIndexFailed: () => {},
- });
-
- const maintainVisibleContentPosition = isScrolling || shouldDisableVisibleContentPosition ? undefined : maintainVisibleContentPositionProp;
-
- const contentContainerStyle = [restProps.contentContainerStyle, shouldHideContent && styles.opacity0];
-
- if (enableAnimatedKeyboardDismissal) {
- return (
-
- );
- }
-
- return (
-
- {...restProps}
- ref={listRef}
- maintainVisibleContentPosition={maintainVisibleContentPosition}
- onScroll={handleScroll}
- onMomentumScrollBegin={handleScrollBegin}
- onMomentumScrollEnd={handleScrollEnd}
- contentContainerStyle={contentContainerStyle}
- />
- );
-}
-
-export default CustomFlatList;
diff --git a/src/components/FlatList/FlatList/index.tsx b/src/components/FlatList/FlatList/index.tsx
deleted file mode 100644
index fb977c60a232..000000000000
--- a/src/components/FlatList/FlatList/index.tsx
+++ /dev/null
@@ -1,279 +0,0 @@
-import useFlatListHandle from '@components/FlatList/hooks/useFlatListHandle';
-import type {FlatListInnerRefType} from '@components/FlatList/types';
-
-import useEmitComposerScrollEvents from '@hooks/useEmitComposerScrollEvents';
-import useThemeStyles from '@hooks/useThemeStyles';
-
-import {isMobileSafari} from '@libs/Browser';
-
-import type {ForwardedRef, RefObject} from 'react';
-import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native';
-
-import React, {useCallback, useEffect, useMemo, useRef} from 'react';
-import {FlatList} from 'react-native';
-
-import type {CustomFlatListProps} from './types';
-
-// Changing the scroll position during a momentum scroll does not work on mobile Safari.
-// We do a best effort to avoid content jumping by using some hacks on mobile Safari only.
-const IS_MOBILE_SAFARI = isMobileSafari();
-
-function mergeRefs(...args: Array | ForwardedRef | null>) {
- return function (node: FlatList) {
- for (const ref of args) {
- if (ref == null) {
- continue;
- }
- if (typeof ref === 'function') {
- ref(node);
- continue;
- }
- if (typeof ref === 'object') {
- ref.current = node;
- continue;
- }
- console.error(`mergeRefs cannot handle Refs of type boolean, number or string, received ref ${String(ref)}`);
- }
- };
-}
-
-function useMergeRefs(...args: Array | ForwardedRef | null>) {
- return useMemo(
- () => mergeRefs(...args),
- // eslint-disable-next-line
- [...args],
- );
-}
-
-function getScrollableNode(flatList: FlatList | null): HTMLElement | undefined {
- return flatList?.getScrollableNode() as HTMLElement | undefined;
-}
-
-function MVCPFlatList({
- maintainVisibleContentPosition,
- horizontal = false,
- onScroll: onScrollProp,
- initialNumToRender,
- shouldHideContent = false,
- ref,
- ...restProps
-}: CustomFlatListProps) {
- const styles = useThemeStyles();
- const {minIndexForVisible: mvcpMinIndexForVisible, autoscrollToTopThreshold: mvcpAutoscrollToTopThreshold} = maintainVisibleContentPosition ?? {};
- const listRef = useRef | null>(null);
- const prevFirstVisibleOffsetRef = useRef(0);
- const firstVisibleViewRef = useRef(null);
- const mutationObserverRef = useRef(null);
- const lastScrollOffsetRef = useRef(0);
- const isListRenderedRef = useRef(false);
- const mvcpAutoscrollToTopThresholdRef = useRef(mvcpAutoscrollToTopThreshold);
- mvcpAutoscrollToTopThresholdRef.current = mvcpAutoscrollToTopThreshold;
-
- const getScrollOffset = useCallback((): number => {
- if (!listRef.current) {
- return 0;
- }
- return horizontal ? (getScrollableNode(listRef.current)?.scrollLeft ?? 0) : (getScrollableNode(listRef.current)?.scrollTop ?? 0);
- }, [horizontal]);
-
- const getContentView = useCallback(() => getScrollableNode(listRef.current)?.childNodes[0], []);
-
- const scrollToOffset = useCallback(
- (offset: number, animated: boolean, interrupt: boolean) => {
- const behavior = animated ? 'smooth' : 'instant';
- const node = getScrollableNode(listRef.current);
- if (node == null) {
- return;
- }
-
- const overflowProp = horizontal ? 'overflowX' : 'overflowY';
- // Stop momentum scrolling on mobile Safari otherwise the scroll position update
- // will not work.
- if (IS_MOBILE_SAFARI && interrupt) {
- node.style[overflowProp] = 'hidden';
- }
- node.scroll(horizontal ? {left: offset, behavior} : {top: offset, behavior});
- if (IS_MOBILE_SAFARI && interrupt) {
- node.style[overflowProp] = 'scroll';
- }
- },
- [horizontal],
- );
-
- const prepareForMaintainVisibleContentPosition = useCallback(() => {
- if (mvcpMinIndexForVisible == null) {
- return;
- }
-
- const contentView = getContentView();
- if (!(contentView instanceof Node)) {
- return;
- }
-
- const scrollOffset = getScrollOffset();
- lastScrollOffsetRef.current = scrollOffset;
-
- const contentViewLength = contentView.childNodes.length;
- for (let i = mvcpMinIndexForVisible; i < contentViewLength; i++) {
- const subview = contentView.childNodes[i] as HTMLElement;
- const subviewOffset = horizontal ? subview.offsetLeft : subview.offsetTop;
- if (subviewOffset > scrollOffset) {
- prevFirstVisibleOffsetRef.current = subviewOffset;
- firstVisibleViewRef.current = subview;
- break;
- }
- }
- }, [getContentView, getScrollOffset, mvcpMinIndexForVisible, horizontal]);
-
- const adjustForMaintainVisibleContentPosition = useCallback(
- (animated = true) => {
- if (mvcpMinIndexForVisible == null) {
- return;
- }
-
- const firstVisibleView = firstVisibleViewRef.current;
- const prevFirstVisibleOffset = prevFirstVisibleOffsetRef.current;
- if (firstVisibleView == null || !firstVisibleView.isConnected || prevFirstVisibleOffset == null) {
- return;
- }
-
- const firstVisibleViewOffset = horizontal ? firstVisibleView.offsetLeft : firstVisibleView.offsetTop;
- const delta = firstVisibleViewOffset - prevFirstVisibleOffset;
- if (Math.abs(delta) > (IS_MOBILE_SAFARI ? 100 : 0.5)) {
- const scrollOffset = lastScrollOffsetRef.current;
- prevFirstVisibleOffsetRef.current = firstVisibleViewOffset;
- scrollToOffset(scrollOffset + delta, false, true);
- if (mvcpAutoscrollToTopThresholdRef.current != null && scrollOffset <= mvcpAutoscrollToTopThresholdRef.current) {
- scrollToOffset(0, animated, false);
- }
- }
- },
- [scrollToOffset, mvcpMinIndexForVisible, horizontal],
- );
-
- const setupMutationObserver = useCallback(() => {
- const contentView = getContentView();
- if (!(contentView instanceof Node)) {
- return;
- }
-
- mutationObserverRef.current?.disconnect();
-
- const mutationObserver = new MutationObserver((mutations) => {
- let isEditComposerAdded = false;
- // Check if the first visible view is removed and re-calculate it
- // if needed.
- for (const mutation of mutations) {
- for (const node of mutation.removedNodes) {
- if (node !== firstVisibleViewRef.current) {
- continue;
- }
- firstVisibleViewRef.current = null;
- }
- for (const node of mutation.addedNodes) {
- if (node.nodeType !== Node.ELEMENT_NODE || !(node as HTMLElement).querySelector('#composer')) {
- continue;
- }
- isEditComposerAdded = true;
- }
- }
-
- if (firstVisibleViewRef.current == null) {
- prepareForMaintainVisibleContentPosition();
- }
-
- // When the list is hidden, the size will be 0.
- // Ignore the callback if the list is hidden because scrollOffset will always be 0.
- if (!getScrollableNode(listRef.current)?.clientHeight) {
- return;
- }
-
- adjustForMaintainVisibleContentPosition(!isEditComposerAdded);
- prepareForMaintainVisibleContentPosition();
- });
- mutationObserver.observe(contentView, {
- attributes: true,
- childList: true,
- subtree: true,
- });
-
- mutationObserverRef.current = mutationObserver;
- }, [adjustForMaintainVisibleContentPosition, prepareForMaintainVisibleContentPosition, getContentView]);
-
- useEffect(() => {
- if (!isListRenderedRef.current) {
- return;
- }
- const animationFrame = requestAnimationFrame(() => {
- prepareForMaintainVisibleContentPosition();
- setupMutationObserver();
- });
- return () => {
- cancelAnimationFrame(animationFrame);
- };
- }, [prepareForMaintainVisibleContentPosition, setupMutationObserver]);
-
- const setMergedRef = useMergeRefs(listRef, ref as ForwardedRef);
-
- const onRef = useCallback(
- (newRef: FlatList) => {
- // Make sure to only call refs and re-attach listeners if the node changed.
- if (newRef == null || newRef === listRef.current) {
- return;
- }
-
- setMergedRef(newRef);
- prepareForMaintainVisibleContentPosition();
- setupMutationObserver();
- },
- [prepareForMaintainVisibleContentPosition, setMergedRef, setupMutationObserver],
- );
-
- useFlatListHandle({
- ref,
- listRef,
- remainingItemsToDisplay: 0,
- setCurrentDataId: () => {},
- onScrollToIndexFailed: () => {},
- });
-
- useEffect(() => {
- const mutationObserver = mutationObserverRef.current;
- return () => {
- mutationObserver?.disconnect();
- mutationObserverRef.current = null;
- };
- }, []);
-
- const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted: restProps.inverted});
- const handleScroll = useCallback(
- (e: NativeSyntheticEvent) => {
- onScrollProp?.(e);
- prepareForMaintainVisibleContentPosition();
- emitComposerScrollEvents();
- },
- [emitComposerScrollEvents, onScrollProp, prepareForMaintainVisibleContentPosition],
- );
- return (
- {
- isListRenderedRef.current = true;
- if (!mutationObserverRef.current) {
- prepareForMaintainVisibleContentPosition();
- setupMutationObserver();
- }
- restProps.onLayout?.(e);
- }}
- contentContainerStyle={[restProps.contentContainerStyle, shouldHideContent && styles.visibilityHidden]}
- />
- );
-}
-
-export default MVCPFlatList;
diff --git a/src/components/FlatList/FlatList/types.ts b/src/components/FlatList/FlatList/types.ts
deleted file mode 100644
index f82f932fe9c7..000000000000
--- a/src/components/FlatList/FlatList/types.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-import type {ForwardedRef} from 'react';
-import type {CellRendererProps, FlatList, FlatListProps} from 'react-native';
-
-type CustomFlatListProps = Omit, 'CellRendererComponent'> & {
- ref?: ForwardedRef;
- shouldDisableVisibleContentPosition?: boolean;
-
- /**
- * Whether to use the animated keyboard handler capabilities on native (iOS and Android)
- * Allows for interactive keyboard dismissal when the user drags the keyboard down
- */
- enableAnimatedKeyboardDismissal?: boolean;
-
- CellRendererComponent?: React.ComponentType> | null;
-
- /**
- * Whether to hide the content (e.g. when first displaying the report actions list, we initially show only the top report actions. We then show the full report actions list after the user scrolls)
- */
- shouldHideContent?: boolean;
-};
-
-// eslint-disable-next-line import/prefer-default-export
-export type {CustomFlatListProps};
diff --git a/src/components/FlatList/hooks/useFlatListHandle.ts b/src/components/FlatList/hooks/useFlatListHandle.ts
deleted file mode 100644
index 159c8b7b2558..000000000000
--- a/src/components/FlatList/hooks/useFlatListHandle.ts
+++ /dev/null
@@ -1,77 +0,0 @@
-import type {FlatListInnerRefType} from '@components/FlatList/types';
-
-import type {ForwardedRef} from 'react';
-// eslint-disable-next-line no-restricted-imports
-import type {FlatList as RNFlatList, ScrollView} from 'react-native';
-
-import {useImperativeHandle} from 'react';
-
-type UseFlatListHandleProps = {
- ref?: ForwardedRef>;
- listRef: React.RefObject | null>;
- setCurrentDataId: (dataId: string | null) => void;
- remainingItemsToDisplay?: number;
- onScrollToIndexFailed?: (params: {index: number; averageItemLength: number; highestMeasuredFrameIndex: number}) => void;
-};
-
-function useFlatListHandle({ref, listRef, setCurrentDataId, remainingItemsToDisplay = 0, onScrollToIndexFailed}: UseFlatListHandleProps) {
- useImperativeHandle(ref, () => {
- // If we're trying to scroll at the start of the list we need to make sure to
- // render all items.
- const scrollToOffsetFn: RNFlatList['scrollToOffset'] = (params) => {
- if (params.offset === 0) {
- setCurrentDataId(null);
- }
- requestAnimationFrame(() => {
- listRef.current?.scrollToOffset(params);
- });
- };
-
- const scrollToEndFn: RNFlatList['scrollToEnd'] = (params) => {
- const scrollViewRef = listRef.current?.getNativeScrollRef();
- // Try to scroll on underlying scrollView if available, fallback to usual listRef
- if (scrollViewRef && 'scrollToEnd' in scrollViewRef) {
- (scrollViewRef as ScrollView).scrollToEnd({animated: !!params?.animated});
- return;
- }
- listRef.current?.scrollToEnd(params);
- };
-
- const scrollToIndexFn: RNFlatList['scrollToIndex'] = (params) => {
- const actualIndex = params.index - remainingItemsToDisplay;
- try {
- listRef.current?.scrollToIndex({...params, index: actualIndex});
- } catch (ex) {
- // It is possible that scrolling fails since the item we are trying to scroll to
- // has not been rendered yet. In this case, we call the onScrollToIndexFailed.
- onScrollToIndexFailed?.({
- index: actualIndex,
- // These metrics are not implemented.
- averageItemLength: 0,
- highestMeasuredFrameIndex: 0,
- });
- }
- };
-
- return new Proxy(
- {},
- {
- get: (_target, prop) => {
- if (prop === 'scrollToOffset') {
- return scrollToOffsetFn;
- }
- if (prop === 'scrollToEnd') {
- return scrollToEndFn;
- }
- if (prop === 'scrollToIndex') {
- return scrollToIndexFn;
- }
- return listRef.current?.[prop as keyof RNFlatList];
- },
- },
- ) as RNFlatList;
- });
-}
-
-export default useFlatListHandle;
-export type {FlatListInnerRefType};
diff --git a/src/components/FlatList/types.ts b/src/components/FlatList/types.ts
deleted file mode 100644
index 3d9344e96b93..000000000000
--- a/src/components/FlatList/types.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import type {FlatList as RNFlatList} from 'react-native';
-
-type FlatListInnerRefType = RNFlatList & HTMLElement;
-
-// eslint-disable-next-line import/prefer-default-export
-export type {FlatListInnerRefType};
diff --git a/src/components/FullscreenLoadingIndicator.tsx b/src/components/FullscreenLoadingIndicator.tsx
index a8501d1615e0..89ae1ced01a8 100644
--- a/src/components/FullscreenLoadingIndicator.tsx
+++ b/src/components/FullscreenLoadingIndicator.tsx
@@ -24,6 +24,8 @@ type FullScreenLoadingIndicatorProps = {
/** Whether the "Go Back" button appears after a timeout. */
shouldUseGoBackButton?: boolean;
+ onGoBack?: () => void;
+
testID?: string;
/** Extra loading context to be passed to the logAppStateOnLongLoading function */
@@ -34,6 +36,7 @@ function FullScreenLoadingIndicator({
style,
iconSize = CONST.ACTIVITY_INDICATOR_SIZE.LARGE,
shouldUseGoBackButton = false,
+ onGoBack = Navigation.goBack,
testID = '',
extraLoadingContext,
}: FullScreenLoadingIndicatorProps) {
@@ -65,7 +68,7 @@ function FullScreenLoadingIndicator({
{translate('common.thisIsTakingLongerThanExpected')}
-
diff --git a/src/components/KYCWall/BaseKYCWall.tsx b/src/components/KYCWall/BaseKYCWall.tsx
index 51bdf4377e4c..5cabfc298086 100644
--- a/src/components/KYCWall/BaseKYCWall.tsx
+++ b/src/components/KYCWall/BaseKYCWall.tsx
@@ -28,7 +28,7 @@ import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import type {Route} from '@src/ROUTES';
import {doesPersonalDetailExistSelector, personalDetailsLoginSelector} from '@src/selectors/PersonalDetails';
-import {lastWorkspaceNumberSelector} from '@src/selectors/Policy';
+import {lastWorkspaceNumberSelector, ownerPoliciesSelector} from '@src/selectors/Policy';
import type {BankAccountList, PersonalDetailsList, Policy} from '@src/types/onyx';
import {getEmptyObject} from '@src/types/utils/EmptyObject';
import viewRef from '@src/types/utils/viewRef';
@@ -223,6 +223,7 @@ function KYCWall({
reportActionsList: filteredReportActions,
doesEmployeePersonalDetailExist: doesSubmitterPersonalDetailExist ?? false,
getCurrencyDecimals,
+ hasOwnedPaidPolicy: ownerPoliciesSelector(policies, currentUserAccountID).length > 0,
}) ?? {};
if (policyID && iouReport?.policyID) {
savePreferredPaymentMethod(iouReport.policyID, policyID, CONST.LAST_PAYMENT_METHOD.IOU, lastPaymentMethod?.[iouReport?.policyID]);
diff --git a/src/components/KeyboardDismissibleFlatList/KeyboardDismissibleFlatListContext.tsx b/src/components/KeyboardDismissibleFlatList/KeyboardDismissibleFlatListContext.tsx
index 6a7d2354fa2a..e229ea82f028 100644
--- a/src/components/KeyboardDismissibleFlatList/KeyboardDismissibleFlatListContext.tsx
+++ b/src/components/KeyboardDismissibleFlatList/KeyboardDismissibleFlatListContext.tsx
@@ -6,7 +6,7 @@ import createDummySharedValue from '@src/utils/createDummySharedValue';
import type {PropsWithChildren} from 'react';
-import React, {createContext, useContext, useState} from 'react';
+import React, {createContext, useState} from 'react';
import {useKeyboardHandler} from 'react-native-keyboard-controller';
import {useAnimatedScrollHandler, useSharedValue} from 'react-native-reanimated';
@@ -143,12 +143,5 @@ function KeyboardDismissibleFlatListContextProvider({children}: PropsWithChildre
);
}
-function useKeyboardDismissibleFlatListState(): KeyboardDismissibleFlatListStateContextValue {
- return useContext(KeyboardDismissibleFlatListStateContext);
-}
-
-function useKeyboardDismissibleFlatListActions(): KeyboardDismissibleFlatListActionsContextValue {
- return useContext(KeyboardDismissibleFlatListActionsContext);
-}
-
-export {KeyboardDismissibleFlatListContextProvider, useKeyboardDismissibleFlatListState, useKeyboardDismissibleFlatListActions};
+// eslint-disable-next-line import/prefer-default-export
+export {KeyboardDismissibleFlatListContextProvider};
diff --git a/src/components/KeyboardDismissibleFlatList/index.ios.tsx b/src/components/KeyboardDismissibleFlatList/index.ios.tsx
deleted file mode 100644
index 5e5d38c8e43c..000000000000
--- a/src/components/KeyboardDismissibleFlatList/index.ios.tsx
+++ /dev/null
@@ -1,57 +0,0 @@
-import type {AnimatedFlatListWithCellRendererProps} from '@components/AnimatedFlatListWithCellRenderer';
-import AnimatedFlatListWithCellRenderer from '@components/AnimatedFlatListWithCellRenderer';
-
-import CONST from '@src/CONST';
-
-import {useEffect} from 'react';
-import {useAnimatedProps, useComposedEventHandler} from 'react-native-reanimated';
-
-import {useKeyboardDismissibleFlatListActions, useKeyboardDismissibleFlatListState} from './KeyboardDismissibleFlatListContext';
-
-function KeyboardDismissibleFlatList({onScroll: onScrollProp, ref, ...restProps}: AnimatedFlatListWithCellRendererProps) {
- const {keyboardHeight, keyboardOffset} = useKeyboardDismissibleFlatListState();
- const {onScroll: onScrollHandleKeyboard, setListBehavior} = useKeyboardDismissibleFlatListActions();
-
- const onScroll = useComposedEventHandler([onScrollHandleKeyboard, onScrollProp ?? null]);
-
- const invertedListAnimatedProps = useAnimatedProps(() => {
- return {
- contentInset: {
- top: keyboardHeight.get(),
- },
- contentOffset: {
- x: 0,
- y: -keyboardHeight.get() + keyboardOffset.get(),
- },
- };
- });
-
- const regularListAnimatedProps = useAnimatedProps(() => {
- return {
- contentInset: {
- bottom: keyboardHeight.get(),
- },
- contentOffset: {
- x: 0,
- y: keyboardHeight.get() + keyboardOffset.get(),
- },
- };
- });
-
- useEffect(() => {
- setListBehavior(restProps.inverted ? CONST.LIST_BEHAVIOR.INVERTED : CONST.LIST_BEHAVIOR.REGULAR);
- }, [restProps.inverted, setListBehavior]);
-
- return (
-
- );
-}
-
-export default KeyboardDismissibleFlatList;
diff --git a/src/components/KeyboardDismissibleFlatList/index.tsx b/src/components/KeyboardDismissibleFlatList/index.tsx
deleted file mode 100644
index 043dba4d39db..000000000000
--- a/src/components/KeyboardDismissibleFlatList/index.tsx
+++ /dev/null
@@ -1,31 +0,0 @@
-import type {AnimatedFlatListWithCellRendererProps} from '@components/AnimatedFlatListWithCellRenderer';
-import AnimatedFlatListWithCellRenderer from '@components/AnimatedFlatListWithCellRenderer';
-
-import useEmitComposerScrollEvents from '@hooks/useEmitComposerScrollEvents';
-
-import React from 'react';
-import {useAnimatedScrollHandler, useComposedEventHandler} from 'react-native-reanimated';
-
-import {useKeyboardDismissibleFlatListActions} from './KeyboardDismissibleFlatListContext';
-
-function KeyboardDismissibleFlatList({onScroll: onScrollProp, inverted, ref, ...restProps}: AnimatedFlatListWithCellRendererProps) {
- const {onScroll: onScrollHandleKeyboard} = useKeyboardDismissibleFlatListActions();
-
- const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted});
-
- const additionalOnScroll = useAnimatedScrollHandler({
- onScroll: emitComposerScrollEvents,
- });
-
- const onScroll = useComposedEventHandler([onScrollHandleKeyboard, additionalOnScroll, onScrollProp ?? null]);
-
- return (
-
- );
-}
-
-export default KeyboardDismissibleFlatList;
diff --git a/src/components/LHNOptionsList/LHNOptionsList.tsx b/src/components/LHNOptionsList/LHNOptionsList.tsx
index e19e60f80ef5..cbe017d7775c 100644
--- a/src/components/LHNOptionsList/LHNOptionsList.tsx
+++ b/src/components/LHNOptionsList/LHNOptionsList.tsx
@@ -1,3 +1,4 @@
+import setLegendListItemZIndex from '@components/LegendList/setLegendListItemZIndex';
import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider';
import useNetwork from '@hooks/useNetwork';
@@ -15,19 +16,18 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Report} from '@src/types/onyx';
-import type {FlashListProps, FlashListRef} from '@shopify/flash-list';
+import type {LegendListProps, LegendListRef} from '@legendapp/list/react-native';
import type {ReactElement} from 'react';
+import {LegendList} from '@legendapp/list/react-native';
import {useRoute} from '@react-navigation/native';
-import {FlashList} from '@shopify/flash-list';
-import React, {memo, useCallback, useContext, useEffect, useMemo, useRef} from 'react';
+import React, {useContext, useEffect, useRef} from 'react';
import {StyleSheet, View} from 'react-native';
import type {LHNOptionsListProps, RenderItemProps} from './types';
import LHNTooltipContextProvider from './LHNTooltipContextProvider';
import OptionRowLHNData from './OptionRowLHN';
-import OptionRowRendererComponent from './OptionRowRendererComponent';
const keyExtractor = (item: Report) => `report_${item.reportID}`;
const platform = getPlatform();
@@ -36,7 +36,7 @@ const isWeb = platform === CONST.PLATFORM.WEB;
function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optionMode, shouldDisableFocusOptions = false, onFirstItemRendered = () => {}}: LHNOptionsListProps) {
const {saveScrollOffset, getScrollOffset, saveScrollIndex, getScrollIndex} = useContext(ScrollOffsetContext);
const {isOffline} = useNetwork();
- const flashListRef = useRef>(null);
+ const legendListRef = useRef(null);
const route = useRoute();
const [reports] = useOnyx(ONYXKEYS.COLLECTION.REPORT);
const reportAttributes = useReportAttributes();
@@ -49,13 +49,49 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio
// When the first item renders we want to call the onFirstItemRendered callback.
// At this point in time we know that the list is actually displaying items.
const hasCalledOnLayout = React.useRef(false);
- const onLayoutItem = useCallback(() => {
+ const onLayoutItem = () => {
if (hasCalledOnLayout.current) {
return;
}
hasCalledOnLayout.current = true;
onFirstItemRendered();
- }, [onFirstItemRendered]);
+ };
+
+ const updateItemZIndex = (index: number) => {
+ if (isWeb) {
+ return;
+ }
+
+ setLegendListItemZIndex(legendListRef.current, index, -index);
+ };
+
+ const updateMountedItemZIndices = () => {
+ if (isWeb || !legendListRef.current) {
+ return;
+ }
+
+ const state = legendListRef.current.getState();
+ const startIndex = Math.max(0, state.startBuffered);
+ const endIndex = Math.min(state.data.length - 1, state.endBuffered);
+ if (!Number.isFinite(startIndex) || !Number.isFinite(endIndex) || endIndex < startIndex) {
+ return;
+ }
+
+ for (let index = startIndex; index <= endIndex; index++) {
+ updateItemZIndex(index);
+ }
+ };
+
+ const handleItemLayout = (index: number) => {
+ onLayoutItem();
+ updateItemZIndex(index);
+ };
+
+ const onViewableItemsChanged: NonNullable['onViewableItemsChanged']> = ({viewableItems}) => {
+ for (const item of viewableItems) {
+ updateItemZIndex(item.index);
+ }
+ };
// Controls the visibility of the educational tooltip based on user scrolling.
// Hides the tooltip when the user is scrolling and displays it once scrolling stops.
@@ -64,94 +100,94 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio
/**
* Function which renders a row in the list
*/
- const renderItem = useCallback(
- ({item, index}: RenderItemProps): ReactElement | null => {
- if (!item) {
- return null;
- }
- const reportID = item.reportID;
- const itemReportAttributes = reportAttributes?.[reportID];
- const itemParentReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${item.parentReportID}`];
- const itemOneTransactionThreadReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${itemReportAttributes?.oneTransactionThreadReportID}`];
-
- let invoiceReceiverPolicyID = '-1';
- if (item.invoiceReceiver && 'policyID' in item.invoiceReceiver) {
- invoiceReceiverPolicyID = item.invoiceReceiver.policyID;
- }
- if (itemParentReport?.invoiceReceiver && 'policyID' in itemParentReport.invoiceReceiver) {
- invoiceReceiverPolicyID = itemParentReport.invoiceReceiver.policyID;
- }
- const itemInvoiceReceiverPolicy = policy?.[`${ONYXKEYS.COLLECTION.POLICY}${invoiceReceiverPolicyID}`];
- const itemPolicy = policy?.[`${ONYXKEYS.COLLECTION.POLICY}${item.policyID}`];
-
- return (
-
- );
- },
- [reportAttributes, reports, policy, personalDetails, optionMode, shouldDisableFocusOptions, onSelectRow, onLayoutItem],
- );
-
- const extraData = useMemo(
- () => [reports, reportAttributes, policy, personalDetails, data.length, optionMode, isOffline],
- [reports, reportAttributes, policy, personalDetails, data.length, optionMode, isOffline],
- );
+ const renderItem = ({item, index}: RenderItemProps): ReactElement | null => {
+ if (!item) {
+ return null;
+ }
+ const reportID = item.reportID;
+ const itemReportAttributes = reportAttributes?.[reportID];
+ const itemParentReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${item.parentReportID}`];
+ const itemOneTransactionThreadReport = reports?.[`${ONYXKEYS.COLLECTION.REPORT}${itemReportAttributes?.oneTransactionThreadReportID}`];
+
+ let invoiceReceiverPolicyID = '-1';
+ if (item.invoiceReceiver && 'policyID' in item.invoiceReceiver) {
+ invoiceReceiverPolicyID = item.invoiceReceiver.policyID;
+ }
+ if (itemParentReport?.invoiceReceiver && 'policyID' in itemParentReport.invoiceReceiver) {
+ invoiceReceiverPolicyID = itemParentReport.invoiceReceiver.policyID;
+ }
+ const itemInvoiceReceiverPolicy = policy?.[`${ONYXKEYS.COLLECTION.POLICY}${invoiceReceiverPolicyID}`];
+ const itemPolicy = policy?.[`${ONYXKEYS.COLLECTION.POLICY}${item.policyID}`];
+
+ return (
+ handleItemLayout(index)}
+ testID={index}
+ />
+ );
+ };
+
+ const extraData = [reports, reportAttributes, policy, personalDetails, data.length, optionMode, isOffline, renderItem];
const previousOptionMode = usePrevious(optionMode);
useEffect(() => {
- if (previousOptionMode === null || previousOptionMode === optionMode || !flashListRef.current) {
+ if (isWeb) {
+ return;
+ }
+
+ const animationFrame = requestAnimationFrame(updateMountedItemZIndices);
+ return () => cancelAnimationFrame(animationFrame);
+ }, [data, updateMountedItemZIndices]);
+
+ useEffect(() => {
+ if (previousOptionMode === null || previousOptionMode === optionMode || !legendListRef.current) {
return;
}
// If the option mode changes want to scroll to the top of the list because rendered items will have different height.
- flashListRef.current.scrollToOffset({offset: 0});
+ legendListRef.current.scrollToOffset({offset: 0});
}, [previousOptionMode, optionMode]);
- const onScroll = useCallback['onScroll']>>(
- (e) => {
- // If the layout measurement is 0, it means the FlashList is not displayed but the onScroll may be triggered with offset value 0.
- // We should ignore this case.
- if (e.nativeEvent.layoutMeasurement.height === 0) {
- return;
- }
- saveScrollOffset(route, e.nativeEvent.contentOffset.y);
- if (isWeb) {
- saveScrollIndex(route, Math.floor(e.nativeEvent.contentOffset.y / estimatedItemSize));
- }
- triggerScrollEvent();
- },
- [estimatedItemSize, route, saveScrollIndex, saveScrollOffset, triggerScrollEvent],
- );
+ const onScroll: NonNullable['onScroll']> = (e) => {
+ // If the layout measurement is 0, it means the LegendList is not displayed but the onScroll may be triggered with offset value 0.
+ // We should ignore this case.
+ if (e.nativeEvent.layoutMeasurement.height === 0) {
+ return;
+ }
+ saveScrollOffset(route, e.nativeEvent.contentOffset.y);
+ if (isWeb) {
+ saveScrollIndex(route, Math.floor(e.nativeEvent.contentOffset.y / estimatedItemSize));
+ }
+ triggerScrollEvent();
+ };
- const onLayout = useCallback(() => {
+ const onLayout = () => {
const offset = getScrollOffset(route);
- if (!(offset && flashListRef.current) || isWeb) {
+ if (!(offset && legendListRef.current) || isWeb) {
return;
}
// We need to use requestAnimationFrame to make sure it will scroll properly on iOS.
requestAnimationFrame(() => {
- if (!(offset && flashListRef.current)) {
+ if (!(offset && legendListRef.current)) {
return;
}
- flashListRef.current.scrollToOffset({offset});
+ legendListRef.current.scrollToOffset({offset});
});
- }, [getScrollOffset, route]);
+ };
const savedScrollIndex = getScrollIndex(route);
const initialScrollIndex = isWeb && savedScrollIndex !== undefined && savedScrollIndex >= 0 && savedScrollIndex < data.length ? savedScrollIndex : undefined;
@@ -159,11 +195,10 @@ function LHNOptionsList({style, contentContainerStyles, data, onSelectRow, optio
return (
-
);
}
-export default memo(LHNOptionsList);
+export default LHNOptionsList;
diff --git a/src/components/LHNOptionsList/OptionRowRendererComponent/index.native.tsx b/src/components/LHNOptionsList/OptionRowRendererComponent/index.native.tsx
deleted file mode 100644
index 9c9fba6d5f61..000000000000
--- a/src/components/LHNOptionsList/OptionRowRendererComponent/index.native.tsx
+++ /dev/null
@@ -1,24 +0,0 @@
-import type {StyleProp, ViewStyle} from 'react-native';
-
-import {View} from 'react-native';
-
-type OptionRowRendererComponentProps = {
- /** The index position of this option row in the list */
- index: number;
-
- onLayout?: () => void;
-
- /** Style prop for customizing the option row */
- style?: StyleProp;
-};
-
-function OptionRowRendererComponent({...props}: OptionRowRendererComponentProps) {
- return (
-
- );
-}
-
-export default OptionRowRendererComponent;
diff --git a/src/components/LHNOptionsList/OptionRowRendererComponent/index.tsx b/src/components/LHNOptionsList/OptionRowRendererComponent/index.tsx
deleted file mode 100644
index 25afb0124e9f..000000000000
--- a/src/components/LHNOptionsList/OptionRowRendererComponent/index.tsx
+++ /dev/null
@@ -1,3 +0,0 @@
-const OptionRowRendererComponent = undefined;
-
-export default OptionRowRendererComponent;
diff --git a/src/components/MenuItem/MenuItemAccessibilityContext.tsx b/src/components/MenuItem/MenuItemAccessibilityContext.tsx
index eb41bd4b9f5e..a464e7ee001f 100644
--- a/src/components/MenuItem/MenuItemAccessibilityContext.tsx
+++ b/src/components/MenuItem/MenuItemAccessibilityContext.tsx
@@ -2,8 +2,11 @@ import type {TupleToUnion, ValueOf} from 'type-fest';
import {createContext, useContext, useEffect, useState} from 'react';
-/** The text slots a `MenuItem` row can contribute to its label, in the order they are announced */
-const MENU_ITEM_LABEL_SLOTS = ['title', 'description'] as const;
+/**
+ * Label slots a `MenuItem` row can contribute, in the order they are announced. Keyed by line rather
+ * than role, so the announced order matches the visual one for both field and navigation rows.
+ */
+const MENU_ITEM_LABEL_SLOTS = ['top', 'bottom'] as const;
type MenuItemLabelSlot = TupleToUnion;
@@ -35,7 +38,7 @@ const MenuItemAccessibilityContext = createContext() {
/** Assembles the row's accessibility label from what its sub-components registered, plus the value for `MenuItemAccessibilityContext.Provider` */
function useMenuItemAccessibility() {
- // Text contributed by Title/Description children, keyed by slot
+ // Text contributed by the text leaves, keyed by the line each one occupies
const {entries: labels, register: registerLabel, unregister: unregisterLabel} = useKeyedRegistry();
// Facts contributed by any child, keyed by the fact
@@ -119,4 +122,5 @@ function useMenuItemAccessibility() {
}
export default MenuItemAccessibilityContext;
+export type {MenuItemLabelSlot};
export {MENU_ITEM_ACCESSIBILITY_ANNOUNCEMENT, useMenuItemAccessibilityLabel, useMenuItemAccessibilityAnnouncement, useMenuItemAccessibility};
diff --git a/src/components/MenuItem/index.ts b/src/components/MenuItem/index.ts
index a91508b60518..f615659f7408 100644
--- a/src/components/MenuItem/index.ts
+++ b/src/components/MenuItem/index.ts
@@ -5,7 +5,7 @@
* imports keep working), extended with the compound sub-components following the
* composition-over-configuration pattern.
*
- * The row's accessibility label is derived from the `Title`/`Description` text, followed by the
+ * The row's accessibility label is derived from the text leaves, announced top line first and followed by the
* hints trailing leaves register.
*
* @example Simple navigation row
@@ -32,11 +32,13 @@ import MenuItemLeading from './layout/MenuItemLeading';
import MenuItemRoot from './layout/MenuItemRoot';
import MenuItemRow from './layout/MenuItemRow';
import MenuItemTrailing from './layout/MenuItemTrailing';
+import MenuItemDescription from './leaves/content/MenuItemDescription';
+import MenuItemFieldName from './leaves/content/MenuItemFieldName';
+import MenuItemFieldNamePlaceholder from './leaves/content/MenuItemFieldNamePlaceholder';
+import MenuItemFieldValue from './leaves/content/MenuItemFieldValue';
+import MenuItemLabel from './leaves/content/MenuItemLabel';
+import MenuItemTitle from './leaves/content/MenuItemTitle';
import MenuItemIcon from './leaves/leading/MenuItemIcon';
-import MenuItemDescription from './leaves/text/description/MenuItemDescription';
-import MenuItemDescriptionPlaceholder from './leaves/text/description/MenuItemDescriptionPlaceholder';
-import MenuItemLabel from './leaves/text/MenuItemLabel';
-import MenuItemTitle from './leaves/text/MenuItemTitle';
import MenuItemChevron from './leaves/trailing/icons/MenuItemChevron';
import MenuItemNewWindowIcon from './leaves/trailing/icons/MenuItemNewWindowIcon';
import MenuItemRightLabel from './leaves/trailing/MenuItemRightLabel';
@@ -52,7 +54,9 @@ const MenuItem = Object.assign(LegacyMenuItem, {
Label: MenuItemLabel,
Title: MenuItemTitle,
Description: MenuItemDescription,
- DescriptionPlaceholder: MenuItemDescriptionPlaceholder,
+ FieldName: MenuItemFieldName,
+ FieldNamePlaceholder: MenuItemFieldNamePlaceholder,
+ FieldValue: MenuItemFieldValue,
Chevron: MenuItemChevron,
NewWindowIcon: MenuItemNewWindowIcon,
RightLabel: MenuItemRightLabel,
diff --git a/src/components/MenuItem/leaves/content/MenuItemDescription.tsx b/src/components/MenuItem/leaves/content/MenuItemDescription.tsx
new file mode 100644
index 000000000000..d003c517de9b
--- /dev/null
+++ b/src/components/MenuItem/leaves/content/MenuItemDescription.tsx
@@ -0,0 +1,22 @@
+import useThemeStyles from '@hooks/useThemeStyles';
+
+import React from 'react';
+
+import type {MenuItemSupportingTextProps} from './base/types';
+
+import BaseMenuItemSupportingText from './base/BaseMenuItemSupportingText';
+
+/** Supporting text under a `MenuItem.Title`, the bottom line of a `MenuItem.Content` */
+function MenuItemDescription(props: MenuItemSupportingTextProps) {
+ const styles = useThemeStyles();
+
+ return (
+
+ );
+}
+
+export default MenuItemDescription;
diff --git a/src/components/MenuItem/leaves/content/MenuItemFieldName.tsx b/src/components/MenuItem/leaves/content/MenuItemFieldName.tsx
new file mode 100644
index 000000000000..4880f9deac52
--- /dev/null
+++ b/src/components/MenuItem/leaves/content/MenuItemFieldName.tsx
@@ -0,0 +1,22 @@
+import useThemeStyles from '@hooks/useThemeStyles';
+
+import React from 'react';
+
+import type {MenuItemSupportingTextProps} from './base/types';
+
+import BaseMenuItemSupportingText from './base/BaseMenuItemSupportingText';
+
+/** Name of a filled field */
+function MenuItemFieldName(props: MenuItemSupportingTextProps) {
+ const styles = useThemeStyles();
+
+ return (
+
+ );
+}
+
+export default MenuItemFieldName;
diff --git a/src/components/MenuItem/leaves/content/MenuItemFieldNamePlaceholder.tsx b/src/components/MenuItem/leaves/content/MenuItemFieldNamePlaceholder.tsx
new file mode 100644
index 000000000000..9425f09e90a2
--- /dev/null
+++ b/src/components/MenuItem/leaves/content/MenuItemFieldNamePlaceholder.tsx
@@ -0,0 +1,24 @@
+import useStyleUtils from '@hooks/useStyleUtils';
+
+import {fontScale, lineHeightScale} from '@styles/typography';
+
+import React from 'react';
+
+import type {MenuItemSupportingTextProps} from './base/types';
+
+import BaseMenuItemSupportingText from './base/BaseMenuItemSupportingText';
+
+/** Name of an empty field, standing in for the missing value */
+function MenuItemFieldNamePlaceholder(props: MenuItemSupportingTextProps) {
+ const StyleUtils = useStyleUtils();
+
+ return (
+
+ );
+}
+
+export default MenuItemFieldNamePlaceholder;
diff --git a/src/components/MenuItem/leaves/content/MenuItemFieldValue.tsx b/src/components/MenuItem/leaves/content/MenuItemFieldValue.tsx
new file mode 100644
index 000000000000..d6bc0a31bb84
--- /dev/null
+++ b/src/components/MenuItem/leaves/content/MenuItemFieldValue.tsx
@@ -0,0 +1,17 @@
+import React from 'react';
+
+import type {MenuItemPrimaryTextProps} from './base/types';
+
+import BaseMenuItemPrimaryText from './base/BaseMenuItemPrimaryText';
+
+/** Value a field holds */
+function MenuItemFieldValue(props: MenuItemPrimaryTextProps) {
+ return (
+
+ );
+}
+
+export default MenuItemFieldValue;
diff --git a/src/components/MenuItem/leaves/content/MenuItemLabel.tsx b/src/components/MenuItem/leaves/content/MenuItemLabel.tsx
new file mode 100644
index 000000000000..c2cfda11ad2f
--- /dev/null
+++ b/src/components/MenuItem/leaves/content/MenuItemLabel.tsx
@@ -0,0 +1,28 @@
+import useThemeStyles from '@hooks/useThemeStyles';
+
+import React from 'react';
+
+import type {MenuItemSupportingTextProps} from './base/types';
+
+import BaseMenuItemSupportingText from './base/BaseMenuItemSupportingText';
+
+/**
+ * Text naming what the row holds, the top line of a `MenuItem.Content`.
+ *
+ * Use this when the label and the value should read as one lockup — it lives inside `Root`, so it
+ * shares the row's press target and hover background. Reach for the `MenuItemWithLabel` preset
+ * instead when the label should stay outside both.
+ */
+function MenuItemLabel(props: MenuItemSupportingTextProps) {
+ const styles = useThemeStyles();
+
+ return (
+
+ );
+}
+
+export default MenuItemLabel;
diff --git a/src/components/MenuItem/leaves/content/MenuItemTitle.tsx b/src/components/MenuItem/leaves/content/MenuItemTitle.tsx
new file mode 100644
index 000000000000..764e7ae35c79
--- /dev/null
+++ b/src/components/MenuItem/leaves/content/MenuItemTitle.tsx
@@ -0,0 +1,22 @@
+import useThemeStyles from '@hooks/useThemeStyles';
+
+import React from 'react';
+
+import type {MenuItemPrimaryTextProps} from './base/types';
+
+import BaseMenuItemPrimaryText from './base/BaseMenuItemPrimaryText';
+
+/** The title block of a `MenuItem.Content`. Bold, single line */
+function MenuItemTitle(props: MenuItemPrimaryTextProps) {
+ const styles = useThemeStyles();
+
+ return (
+
+ );
+}
+
+export default MenuItemTitle;
diff --git a/src/components/MenuItem/leaves/text/MenuItemTitle.tsx b/src/components/MenuItem/leaves/content/base/BaseMenuItemPrimaryText.tsx
similarity index 51%
rename from src/components/MenuItem/leaves/text/MenuItemTitle.tsx
rename to src/components/MenuItem/leaves/content/base/BaseMenuItemPrimaryText.tsx
index d9fbf40d7fc0..fd95c1b6a980 100644
--- a/src/components/MenuItem/leaves/text/MenuItemTitle.tsx
+++ b/src/components/MenuItem/leaves/content/base/BaseMenuItemPrimaryText.tsx
@@ -8,34 +8,20 @@ import convertToLTR from '@libs/convertToLTR';
import CONST from '@src/CONST';
-import type {ReactElement} from 'react';
-
import React from 'react';
-type MenuItemTitleProps =
- | {
- /** Text to render as the title */
- children: string | number;
-
- accessibilityLabel?: never;
- }
- | {
- /** Element to render in place of plain text, e.g. a `DisplayNames` with per-name tooltips */
- children: ReactElement;
-
- accessibilityLabel: string;
- };
+import type {BaseMenuItemTextProps, MenuItemPrimaryTextProps} from './types';
-/** The title block of a `MenuItem.Content`. Bold, single line */
-function MenuItemTitle({children, accessibilityLabel}: MenuItemTitleProps) {
+/** Base of the full-contrast leaves */
+function BaseMenuItemPrimaryText({children, accessibilityLabel, slot, style}: MenuItemPrimaryTextProps & BaseMenuItemTextProps) {
const styles = useThemeStyles();
const {isDisabled, isInteractive} = useMenuItemConfig();
- useMenuItemAccessibilityLabel('title', accessibilityLabel ?? String(children));
+ useMenuItemAccessibilityLabel(slot, accessibilityLabel ?? String(children));
return (
@@ -44,4 +30,4 @@ function MenuItemTitle({children, accessibilityLabel}: MenuItemTitleProps) {
);
}
-export default MenuItemTitle;
+export default BaseMenuItemPrimaryText;
diff --git a/src/components/MenuItem/leaves/content/base/BaseMenuItemSupportingText.tsx b/src/components/MenuItem/leaves/content/base/BaseMenuItemSupportingText.tsx
new file mode 100644
index 000000000000..7c7c5744a256
--- /dev/null
+++ b/src/components/MenuItem/leaves/content/base/BaseMenuItemSupportingText.tsx
@@ -0,0 +1,26 @@
+import {useMenuItemAccessibilityLabel} from '@components/MenuItem/MenuItemAccessibilityContext';
+import Text from '@components/Text';
+
+import useThemeStyles from '@hooks/useThemeStyles';
+
+import React from 'react';
+
+import type {BaseMenuItemTextProps, MenuItemSupportingTextProps} from './types';
+
+/** Base of the muted leaves */
+function BaseMenuItemSupportingText({children, numberOfLines = 2, slot, style}: MenuItemSupportingTextProps & BaseMenuItemTextProps) {
+ const styles = useThemeStyles();
+
+ useMenuItemAccessibilityLabel(slot, String(children));
+
+ return (
+
+ {children}
+
+ );
+}
+
+export default BaseMenuItemSupportingText;
diff --git a/src/components/MenuItem/leaves/content/base/types.ts b/src/components/MenuItem/leaves/content/base/types.ts
new file mode 100644
index 000000000000..61ce842c11c2
--- /dev/null
+++ b/src/components/MenuItem/leaves/content/base/types.ts
@@ -0,0 +1,40 @@
+import type {MenuItemLabelSlot} from '@components/MenuItem/MenuItemAccessibilityContext';
+
+import type {ReactElement} from 'react';
+import type {StyleProp, TextStyle} from 'react-native';
+
+/** Props of the full-contrast leaves */
+type MenuItemPrimaryTextProps =
+ | {
+ /** Plain text to render as the primary content */
+ children: string | number;
+
+ accessibilityLabel?: never;
+ }
+ | {
+ /** For content plain text can't express, e.g. `DisplayNames` with per-name tooltips */
+ children: ReactElement;
+
+ /** Required here because the row builds its label from strings and can't read one out of an element */
+ accessibilityLabel: string;
+ };
+
+/** Props of the muted leaves */
+type MenuItemSupportingTextProps = {
+ /** Text to render */
+ children: string | number;
+
+ /** Defaults to 2. Supporting text wraps, unlike primary text, which is always one line */
+ numberOfLines?: number;
+};
+
+/** Props shared by MenuItem's content text */
+type BaseMenuItemTextProps = {
+ /** Which line of the row this leaf occupies, so the row announces its text in visual order */
+ slot: MenuItemLabelSlot;
+
+ /** Typography layered on top of the shared base. Each leaf brings its own size and line height */
+ style?: StyleProp;
+};
+
+export type {MenuItemSupportingTextProps, MenuItemPrimaryTextProps, BaseMenuItemTextProps};
diff --git a/src/components/MenuItem/leaves/text/MenuItemLabel.tsx b/src/components/MenuItem/leaves/text/MenuItemLabel.tsx
deleted file mode 100644
index 90e254f11e83..000000000000
--- a/src/components/MenuItem/leaves/text/MenuItemLabel.tsx
+++ /dev/null
@@ -1,25 +0,0 @@
-import Text from '@components/Text';
-
-import useThemeStyles from '@hooks/useThemeStyles';
-
-import React from 'react';
-
-type MenuItemLabelProps = {
- /** Text naming what the row holds */
- children: string;
-};
-
-/**
- * The label block of a `MenuItem.Content`, sitting above the row's value.
- *
- * Use this when the label and the value should read as one lockup — it lives inside `Root`, so it
- * shares the row's press target and hover background. Reach for the `MenuItemWithLabel` preset
- * instead when the label should stay outside both.
- */
-function MenuItemLabel({children}: MenuItemLabelProps) {
- const styles = useThemeStyles();
-
- return {children};
-}
-
-export default MenuItemLabel;
diff --git a/src/components/MenuItem/leaves/text/description/MenuItemDescription.tsx b/src/components/MenuItem/leaves/text/description/MenuItemDescription.tsx
deleted file mode 100644
index adeca7966a67..000000000000
--- a/src/components/MenuItem/leaves/text/description/MenuItemDescription.tsx
+++ /dev/null
@@ -1,26 +0,0 @@
-import {useMenuItemAccessibilityLabel} from '@components/MenuItem/MenuItemAccessibilityContext';
-import Text from '@components/Text';
-
-import useThemeStyles from '@hooks/useThemeStyles';
-
-import React from 'react';
-
-import type MenuItemDescriptionProps from './types';
-
-/** The small supporting-label description of a `MenuItem.Content`, for a description that sits under a title */
-function MenuItemDescription({children, numberOfLines = 2}: MenuItemDescriptionProps) {
- const styles = useThemeStyles();
-
- useMenuItemAccessibilityLabel('description', String(children));
-
- return (
-
- {children}
-
- );
-}
-
-export default MenuItemDescription;
diff --git a/src/components/MenuItem/leaves/text/description/MenuItemDescriptionPlaceholder.tsx b/src/components/MenuItem/leaves/text/description/MenuItemDescriptionPlaceholder.tsx
deleted file mode 100644
index 7aa7233d481c..000000000000
--- a/src/components/MenuItem/leaves/text/description/MenuItemDescriptionPlaceholder.tsx
+++ /dev/null
@@ -1,30 +0,0 @@
-import {useMenuItemAccessibilityLabel} from '@components/MenuItem/MenuItemAccessibilityContext';
-import Text from '@components/Text';
-
-import useStyleUtils from '@hooks/useStyleUtils';
-import useThemeStyles from '@hooks/useThemeStyles';
-
-import {fontScale, lineHeightScale} from '@styles/typography';
-
-import React from 'react';
-
-import type MenuItemDescriptionProps from './types';
-
-/** The normal-size description of a `MenuItem.Content`, for a description-only row */
-function MenuItemDescriptionPlaceholder({children, numberOfLines = 2}: MenuItemDescriptionProps) {
- const styles = useThemeStyles();
- const StyleUtils = useStyleUtils();
-
- useMenuItemAccessibilityLabel('description', String(children));
-
- return (
-
- {children}
-
- );
-}
-
-export default MenuItemDescriptionPlaceholder;
diff --git a/src/components/MenuItem/leaves/text/description/types.ts b/src/components/MenuItem/leaves/text/description/types.ts
deleted file mode 100644
index 8b2b782a0d2e..000000000000
--- a/src/components/MenuItem/leaves/text/description/types.ts
+++ /dev/null
@@ -1,10 +0,0 @@
-/** Props shared by every description leaf of a `MenuItem.Content` */
-type MenuItemDescriptionProps = {
- /** Text to render as the description */
- children: string | number;
-
- /** Maximum number of lines to render before the text is truncated */
- numberOfLines?: number;
-};
-
-export default MenuItemDescriptionProps;
diff --git a/src/components/MenuItem/leaves/trailing/icons/MenuItemChevron.tsx b/src/components/MenuItem/leaves/trailing/icons/MenuItemChevron.tsx
index a07c4a88305c..88c3322639a9 100644
--- a/src/components/MenuItem/leaves/trailing/icons/MenuItemChevron.tsx
+++ b/src/components/MenuItem/leaves/trailing/icons/MenuItemChevron.tsx
@@ -22,7 +22,10 @@ function MenuItemChevron() {
const {isHovered} = useMenuItemInteraction();
return (
-
+
void | Promise;
-
- /** Whether the menu item is disabled */
- isDisabled?: boolean;
- };
-
-/** The empty-field MenuItem preset — a form field the user has not filled in yet */
-function MenuItemEmptyField({description, onPress, children, isDisabled = false, sentryLabel, testID}: MenuItemEmptyFieldProps) {
- return (
-
-
-
- {description}
-
-
- {children}
-
-
-
-
- );
-}
-
-export default MenuItemEmptyField;
diff --git a/src/components/MenuItem/presets/MenuItemField.tsx b/src/components/MenuItem/presets/MenuItemField.tsx
new file mode 100644
index 000000000000..81308f96e27d
--- /dev/null
+++ b/src/components/MenuItem/presets/MenuItemField.tsx
@@ -0,0 +1,54 @@
+import MenuItemContent from '@components/MenuItem/layout/MenuItemContent';
+import MenuItemRoot from '@components/MenuItem/layout/MenuItemRoot';
+import type {MenuItemRootProps} from '@components/MenuItem/layout/MenuItemRoot';
+import MenuItemRow from '@components/MenuItem/layout/MenuItemRow';
+import MenuItemTrailing from '@components/MenuItem/layout/MenuItemTrailing';
+import MenuItemFieldName from '@components/MenuItem/leaves/content/MenuItemFieldName';
+import MenuItemFieldNamePlaceholder from '@components/MenuItem/leaves/content/MenuItemFieldNamePlaceholder';
+import MenuItemFieldValue from '@components/MenuItem/leaves/content/MenuItemFieldValue';
+import MenuItemChevron from '@components/MenuItem/leaves/trailing/icons/MenuItemChevron';
+
+import {callFunctionIfActionIsAllowed} from '@userActions/Session';
+
+import React from 'react';
+
+type MenuItemFieldProps = Omit & {
+ /** Name of the field */
+ name: string;
+
+ /** Value the field holds. Omit it, or pass an empty string, for a field not filled in yet */
+ value?: string;
+};
+
+/** Field preset: a field name plus its value. With no `value` the name takes over the row */
+function MenuItemField({name, value, children, onPress, isDisabled = false, sentryLabel, testID}: MenuItemFieldProps) {
+ return (
+
+
+
+ {value ? (
+ <>
+ {name}
+ {value}
+ >
+ ) : (
+ {name}
+ )}
+
+ {(!!children || !!onPress) && (
+
+ {children}
+ {!!onPress && }
+
+ )}
+
+
+ );
+}
+
+export default MenuItemField;
diff --git a/src/components/MenuItem/presets/MenuItemNavigation.tsx b/src/components/MenuItem/presets/MenuItemNavigation.tsx
index 8154b2fd492c..349517e1cc3f 100644
--- a/src/components/MenuItem/presets/MenuItemNavigation.tsx
+++ b/src/components/MenuItem/presets/MenuItemNavigation.tsx
@@ -3,8 +3,8 @@ import MenuItemLeading from '@components/MenuItem/layout/MenuItemLeading';
import MenuItemRoot from '@components/MenuItem/layout/MenuItemRoot';
import MenuItemRow from '@components/MenuItem/layout/MenuItemRow';
import MenuItemTrailing from '@components/MenuItem/layout/MenuItemTrailing';
+import MenuItemTitle from '@components/MenuItem/leaves/content/MenuItemTitle';
import MenuItemIcon from '@components/MenuItem/leaves/leading/MenuItemIcon';
-import MenuItemTitle from '@components/MenuItem/leaves/text/MenuItemTitle';
import MenuItemChevron from '@components/MenuItem/leaves/trailing/icons/MenuItemChevron';
import {callFunctionIfActionIsAllowed} from '@userActions/Session';
diff --git a/src/components/MenuItem/presets/MenuItemWithLabel.tsx b/src/components/MenuItem/presets/MenuItemWithLabel.tsx
index 5c0a82a28838..b276bf3595d5 100644
--- a/src/components/MenuItem/presets/MenuItemWithLabel.tsx
+++ b/src/components/MenuItem/presets/MenuItemWithLabel.tsx
@@ -1,6 +1,6 @@
import type {MenuItemRootProps} from '@components/MenuItem/layout/MenuItemRoot';
import MenuItemRoot from '@components/MenuItem/layout/MenuItemRoot';
-import Text from '@components/Text';
+import MenuItemLabel from '@components/MenuItem/leaves/content/MenuItemLabel';
import useThemeStyles from '@hooks/useThemeStyles';
@@ -24,7 +24,7 @@ function MenuItemWithLabel({label, onPress, isDisabled = false, sentryLabel, tes
return (
- {label}
+ {label}
void;
- /** When set, used in the new manual expense flow to open the parent-owned participant picker instead of navigating away */
- onOpenParticipantPicker?: () => void;
+ /** Opens the participant picker owned by the page hosting this list. Pages that cannot show an editable participant row pass a no-op. */
+ onOpenParticipantPicker: () => void;
/** Whether the parent-owned participant picker modal is currently open (new manual expense flow). Drives amount autofocus on picker close. */
isParticipantPickerVisible?: boolean;
@@ -202,8 +198,6 @@ function MoneyRequestConfirmationList({
const transactionReport = useTransactionReportForConfirmation(transaction?.reportID);
const {policyForMovingExpenses, shouldSelectPolicy} = usePolicyForMovingExpenses();
const isMovingTransactionFromTrackExpense = isMovingTransactionFromTrackExpenseUtil(action);
- const {isBetaEnabled} = usePermissions();
- const isNewManualExpenseFlowEnabled = isBetaEnabled(CONST.BETAS.NEW_MANUAL_EXPENSE_FLOW);
const {isDelegateAccessRestricted} = useDelegateNoAccessState();
const {showDelegateNoAccessModal} = useDelegateNoAccessActions();
const isInLandscapeMode = useIsInLandscapeMode();
@@ -250,7 +244,6 @@ function MoneyRequestConfirmationList({
const isTypeRequest = iouType === CONST.IOU.TYPE.SUBMIT;
const isTypeSend = iouType === CONST.IOU.TYPE.PAY;
- const isTypeTrackExpense = iouType === CONST.IOU.TYPE.TRACK;
const isTypeInvoice = iouType === CONST.IOU.TYPE.INVOICE;
const isFromGlobalCreateAndCanEditParticipant = !!transaction?.isFromGlobalCreate && !isPerDiemRequest && !isTimeRequest;
@@ -313,7 +306,7 @@ function MoneyRequestConfirmationList({
});
const isManualRequest = transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL;
- const shouldForceTopEmptySections = isNewManualExpenseFlowEnabled && (iouType === CONST.IOU.TYPE.CREATE || isManualRequest || isScanRequest);
+ const shouldForceTopEmptySections = iouType === CONST.IOU.TYPE.CREATE || isManualRequest || isScanRequest;
const isFocused = useIsFocused();
@@ -353,7 +346,6 @@ function MoneyRequestConfirmationList({
routeError,
isTypeSplit,
shouldShowReadOnlySplits,
- isNewManualExpenseFlowEnabled,
isDistanceRequest,
isReadOnly,
shouldShowDate,
@@ -375,7 +367,6 @@ function MoneyRequestConfirmationList({
const splitOrRequestOptions = useConfirmationCtaText({
expensesNumber,
isTypeInvoice,
- isTypeTrackExpense,
isTypeSplit,
isTypeRequest,
iouAmount,
@@ -385,7 +376,6 @@ function MoneyRequestConfirmationList({
receiptPath,
isDistanceRequestWithPendingRoute,
isPerDiemRequest,
- isNewManualExpenseFlowEnabled,
});
const selectedParticipants = selectedParticipantsProp.filter((participant) => participant.selected);
@@ -443,13 +433,7 @@ function MoneyRequestConfirmationList({
return;
}
- if (isNewManualExpenseFlowEnabled) {
- onOpenParticipantPicker?.();
- return;
- }
-
- const newIOUType = iouType === CONST.IOU.TYPE.SUBMIT || iouType === CONST.IOU.TYPE.TRACK ? CONST.IOU.TYPE.CREATE : iouType;
- Navigation.navigate(createDynamicRoute(DYNAMIC_ROUTES.MONEY_REQUEST_STEP_PARTICIPANTS.getRoute({action, iouType: newIOUType, transactionID, reportID: transaction?.reportID})));
+ onOpenParticipantPicker();
};
const {validate} = useConfirmationValidation({
@@ -480,7 +464,6 @@ function MoneyRequestConfirmationList({
isMovingTransactionFromTrackExpense,
isTimeRequest,
routeError,
- isNewManualExpenseFlowEnabled,
isReadOnly,
shouldShowDate,
isTaxAmountEmpty,
@@ -545,7 +528,6 @@ function MoneyRequestConfirmationList({
isReadOnly={isReadOnly}
didConfirm={!!didConfirm}
isEditingSplitBill={isEditingSplitBill}
- isNewManualExpenseFlowEnabled={isNewManualExpenseFlowEnabled}
isPolicyExpenseChat={isPolicyExpenseChat}
isScanRequest={isScanRequest}
isDistanceRequest={isDistanceRequest}
diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationCtaText.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationCtaText.ts
index d599da7c823f..a109c1d747b1 100644
--- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationCtaText.ts
+++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationCtaText.ts
@@ -16,9 +16,6 @@ type UseConfirmationCtaTextParams = {
/** Whether the current IOU type is invoice */
isTypeInvoice: boolean;
- /** Whether the current IOU type is track-expense */
- isTypeTrackExpense: boolean;
-
/** Whether the current IOU type is split */
isTypeSplit: boolean;
@@ -45,22 +42,18 @@ type UseConfirmationCtaTextParams = {
/** Whether the transaction is a per-diem request */
isPerDiemRequest: boolean;
-
- /** Whether the new manual expense flow beta is enabled */
- isNewManualExpenseFlowEnabled: boolean;
};
/**
* Computes the primary confirm button label for the Money Request confirmation flow.
*
- * Picks between create / create-with-amount / split / invoice / next variants based on
- * the IOU type, manual-expense-flow beta, bulk-expense count, and amount, returning a
- * single-entry DropdownOption array shaped for the ButtonWithDropdownMenu consumer.
+ * Picks between create / split / invoice / next variants based on the IOU type and
+ * bulk-expense count, returning a single-entry DropdownOption array shaped for the
+ * ButtonWithDropdownMenu consumer.
*/
function useConfirmationCtaText({
expensesNumber,
isTypeInvoice,
- isTypeTrackExpense,
isTypeSplit,
isTypeRequest,
iouAmount,
@@ -70,7 +63,6 @@ function useConfirmationCtaText({
receiptPath,
isDistanceRequestWithPendingRoute,
isPerDiemRequest,
- isNewManualExpenseFlowEnabled,
}: UseConfirmationCtaTextParams): Array> {
const {translate} = useLocalize();
@@ -83,29 +75,16 @@ function useConfirmationCtaText({
} else {
text = translate('common.next');
}
- } else if (isTypeTrackExpense) {
- text = translate('iou.createExpense');
- if (iouAmount !== 0 && !isNewManualExpenseFlowEnabled) {
- text = translate('iou.createExpenseWithAmount', {amount: formattedAmount});
- }
} else if (isTypeSplit && iouAmount === 0) {
text = translate('iou.splitExpense');
} else if ((receiptPath && isTypeRequest) || isDistanceRequestWithPendingRoute || isPerDiemRequest) {
+ // Sits between the two split branches on purpose: a non-zero split with a pending distance route or per diem
+ // keeps the create copy rather than falling through to "Split expense".
text = translate('iou.createExpense');
- if (iouAmount !== 0 && !isNewManualExpenseFlowEnabled) {
- text = translate('iou.createExpenseWithAmount', {amount: formattedAmount});
- }
} else if (isTypeSplit) {
- text = translate('iou.splitAmount', formattedAmount);
- if (isNewManualExpenseFlowEnabled) {
- text = translate('iou.splitExpense');
- }
- } else if (iouAmount === 0) {
- text = translate('iou.createExpense');
- } else if (isNewManualExpenseFlowEnabled) {
- text = translate('iou.createExpense');
+ text = translate('iou.splitExpense');
} else {
- text = translate('iou.createExpenseWithAmount', {amount: formattedAmount});
+ text = translate('iou.createExpense');
}
return [
{
diff --git a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts
index a1b3160fc062..9ad71cb5aa46 100644
--- a/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts
+++ b/src/components/MoneyRequestConfirmationList/hooks/useConfirmationValidation.ts
@@ -113,8 +113,6 @@ type UseConfirmationValidationParams = {
/** Truthy when the route to the confirmation page has a known error */
routeError: string | null | undefined;
- isNewManualExpenseFlowEnabled: boolean;
-
/** Whether the confirmation fields are read-only (date is not inline-editable) */
isReadOnly: boolean;
@@ -167,7 +165,6 @@ function useConfirmationValidation({
isMovingTransactionFromTrackExpense,
isTimeRequest,
routeError,
- isNewManualExpenseFlowEnabled,
isReadOnly,
shouldShowDate,
isTaxAmountEmpty,
@@ -190,11 +187,10 @@ function useConfirmationValidation({
if (!isScanRequestUtil(transaction) && !isTimeRequest && !isDistanceRequest && iouAmount === 0 && isP2P) {
return {errorKey: 'common.error.invalidAmount'};
}
- if (isNewManualExpenseFlowEnabled && isConfirmationAmountMissing(transaction)) {
+ if (isConfirmationAmountMissing(transaction)) {
return {errorKey: 'common.error.fieldRequired'};
}
if (
- isNewManualExpenseFlowEnabled &&
transaction?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL &&
transaction?.isAmountSet &&
!isScanRequestUtil(transaction) &&
@@ -205,9 +201,9 @@ function useConfirmationValidation({
) {
return {errorKey: 'common.error.invalidAmount'};
}
- // The date is an inline, clearable required field in the new manual flow for every type that shows it
- // (manual, distance, time, invoice, ...). Block confirmation when the user cleared it.
- if (isNewManualExpenseFlowEnabled && isConfirmationDateMissing(transaction, shouldShowDate, isReadOnly)) {
+ // The date is an inline, clearable required field for every type that shows it (manual, distance, time,
+ // invoice, ...). Block confirmation when the user cleared it.
+ if (isConfirmationDateMissing(transaction, shouldShowDate, isReadOnly)) {
return {errorKey: 'common.error.fieldRequired'};
}
const merchantValue = iouMerchant ?? '';
@@ -267,14 +263,14 @@ function useConfirmationValidation({
return {errorKey: 'violations.taxOutOfPolicy'};
}
- if (isNewManualExpenseFlowEnabled && shouldShowTax && !isDistanceRequest && isTaxAmountEmpty) {
+ if (shouldShowTax && !isDistanceRequest && isTaxAmountEmpty) {
return {errorKey: 'iou.error.invalidAmount'};
}
- // In the new manual expense flow the tax amount is edited inline, so the standalone tax amount step's
+ // In the manual expense flow the tax amount is edited inline, so the standalone tax amount step's
// guard (tax amount can't exceed the tax computed from the rate and the expense amount) runs here.
// This also blocks creation when an invalid tax amount was persisted to the draft and then reloaded.
- if (isNewManualExpenseFlowEnabled && shouldShowTax && !isDistanceRequest) {
+ if (shouldShowTax && !isDistanceRequest) {
const decimals = getCurrencyDecimals(iouCurrencyCode);
const maxTaxAmount = getCalculatedTaxAmount(policy, transaction, iouCurrencyCode, decimals);
const currentTaxAmount = convertToFrontendAmountAsString(Math.abs(getTaxAmount(transaction, false)), decimals);
diff --git a/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts b/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts
index 6c4a6a0899c1..752f6eeb3c25 100644
--- a/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts
+++ b/src/components/MoneyRequestConfirmationList/hooks/useFormErrorManagement.ts
@@ -72,9 +72,6 @@ type UseFormErrorManagementParams = {
/** Whether splits are rendered read-only (suppresses some field errors) */
shouldShowReadOnlySplits: boolean;
- /** Whether the new manual expense flow is enabled (amount/date errors surface inline) */
- isNewManualExpenseFlowEnabled: boolean;
-
/** Whether the transaction is a distance request (its amount is read-only, so amount errors are not shown inline) */
isDistanceRequest: boolean;
@@ -147,7 +144,6 @@ function useFormErrorManagement({
routeError,
isTypeSplit,
shouldShowReadOnlySplits,
- isNewManualExpenseFlowEnabled,
isDistanceRequest,
shouldShowDate,
isReadOnly,
@@ -222,11 +218,11 @@ function useFormErrorManagement({
const isAmountRequiredMissing = isConfirmationAmountMissing(transaction);
const isDateRequiredMissing = isConfirmationDateMissing(transaction, shouldShowDate, isReadOnly);
useEffect(() => {
- if (!isNewManualExpenseFlowEnabled || formErrorRef.current !== 'common.error.fieldRequired' || isAmountRequiredMissing || isDateRequiredMissing) {
+ if (formErrorRef.current !== 'common.error.fieldRequired' || isAmountRequiredMissing || isDateRequiredMissing) {
return;
}
setFormError('');
- }, [isNewManualExpenseFlowEnabled, isAmountRequiredMissing, isDateRequiredMissing, setFormError]);
+ }, [isAmountRequiredMissing, isDateRequiredMissing, setFormError]);
useEffect(() => {
const currentFormError = formErrorRef.current;
@@ -253,27 +249,27 @@ function useFormErrorManagement({
}
}, [isFocused, shouldDisplayFieldError, hasSmartScanFailed, didConfirmSplit, isViolationFixed, setFormError]);
- // In the new manual expense flow the amount/date/merchant fields surface these required/invalid errors inline, so
- // repeating them at the bottom of the form would show "This field is required" twice.
+ // The amount/date/merchant fields surface these required/invalid errors inline, so repeating them at the bottom of
+ // the form would show "This field is required" twice.
// `common.error.invalidAmount` is the one exception: it is only surfaced inline while the editable amount input is
// rendered. Distance requests disable that input, and the read-only menu row it falls back to doesn't show the error,
// so the distance-amount error stays in the footer. Otherwise an invalid distance expense would fail silently.
- const isSuppressedInNewFlow = (error: TranslationPaths | ''): boolean =>
- isNewManualExpenseFlowEnabled && (error === 'common.error.fieldRequired' || error === 'iou.error.invalidMerchant' || (!isDistanceRequest && error === 'common.error.invalidAmount'));
+ const isSuppressedInline = (error: TranslationPaths | ''): boolean =>
+ error === 'common.error.fieldRequired' || error === 'iou.error.invalidMerchant' || (!isDistanceRequest && error === 'common.error.invalidAmount');
const computeErrorMessage = (): string | undefined => {
if (routeError) {
return routeError;
}
// This runs ahead of the split branch below because splits render those same inline fields, so they duplicate the same way.
- if (isSuppressedInNewFlow(formError)) {
+ if (isSuppressedInline(formError)) {
return undefined;
}
if (isTypeSplit && !shouldShowReadOnlySplits) {
// Splits render the debounced value, so the suppression has to be re-checked against it. `formError` clears
// the instant the user fixes the field while `debouncedFormError` lags by USE_DEBOUNCED_STATE_DELAY, and
// without this the suppressed error would pop into the footer for that window (#96565).
- return debouncedFormError && !isSuppressedInNewFlow(debouncedFormError) ? translate(debouncedFormError) : undefined;
+ return debouncedFormError && !isSuppressedInline(debouncedFormError) ? translate(debouncedFormError) : undefined;
}
// Don't show error at the bottom of the form for missing attendees — the field surfaces it inline.
if (formError === 'violations.missingAttendees') {
diff --git a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx
index fb470f3fe845..10819d0b7cf4 100644
--- a/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx
+++ b/src/components/MoneyRequestConfirmationList/sections/AmountField.tsx
@@ -66,7 +66,7 @@ function AmountField({
isParticipantPickerVisible = false,
}: AmountFieldProps) {
const shouldAutoFocusOnMount = !canUseTouchScreen();
- const {isEditingSplitBill, isNewManualExpenseFlowEnabled, isReadOnly, didConfirm, transactionID, action, iouType, reportID, reportActionID} = useConfirmationFields();
+ const {isEditingSplitBill, isReadOnly, didConfirm, transactionID, action, iouType, reportID, reportActionID} = useConfirmationFields();
const styles = useThemeStyles();
const {translate, preferredLocale} = useLocalize();
const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions();
@@ -84,10 +84,7 @@ function AmountField({
const [isCurrencyPickerVisible, setIsCurrencyPickerVisible] = useState(false);
const isAmountFieldDisabled = didConfirm || isReadOnly || shouldShowTimeRequestFields || isDistanceRequest;
- const firstParticipant = transactionSlice?.participants?.at(0);
- const isP2P = isNewManualExpenseFlowEnabled
- ? isParticipantP2P(getMoneyRequestParticipantsFromReport(report, currentUserPersonalDetails.accountID).at(0))
- : !!(firstParticipant?.accountID && !firstParticipant?.isPolicyExpenseChat);
+ const isP2P = isParticipantP2P(getMoneyRequestParticipantsFromReport(report, currentUserPersonalDetails.accountID).at(0));
// `common.error.fieldRequired` is shared with the date field, so only surface it on the amount input when the
// amount itself is the missing value. `isConfirmationAmountMissing` is the same predicate validation raises the
// error from, so a scan expense (where the amount is populated programmatically and `isAmountSet` is never set)
@@ -104,21 +101,20 @@ function AmountField({
const effectiveCurrency = isDistanceRequest ? distanceRateCurrency : (iouCurrencyCode ?? CONST.CURRENCY.USD);
const decimals = getCurrencyDecimals(effectiveCurrency);
- // In the new manual expense flow the amount field starts empty (transaction.amount defaults to 0 before the user
+ // In the manual expense flow the amount field starts empty (transaction.amount defaults to 0 before the user
// touches it). Once the user explicitly sets an amount – including 0 – isAmountSet becomes true and we show the
// real value. This avoids showing "$0.00" as a pre-filled default. Scan and other non-manual flows populate
// amount programmatically and never set isAmountSet.
- const shouldShowEmptyAmount = isNewManualExpenseFlowEnabled && !transactionSlice?.isAmountSet && transactionSlice?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL;
+ const shouldShowEmptyAmount = !transactionSlice?.isAmountSet && transactionSlice?.iouRequestType === CONST.IOU.REQUEST_TYPE.MANUAL;
const transactionAmount = shouldShowEmptyAmount ? '' : convertToFrontendAmountAsString(amount, decimals);
- const allowNegative = shouldEnableNegative(report, policy, iouType, transactionSlice?.participants, isNewManualExpenseFlowEnabled);
+ const allowNegative = shouldEnableNegative(report, policy, iouType, transactionSlice?.participants);
// `autoFocus` on our TextInput only runs on mount. Closing and reopening the RHP often keeps the same mounted
// instance, so autofocus does not run again. We re-focus when the parent-owned participant picker closes
- // (visible → hidden) so the amount input gains focus once the user selects a participant in the new manual
- // expense flow. The setTimeout defers focus past the RHP entry / picker close animation so the input reliably
- // receives focus.
+ // (visible → hidden) so the amount input gains focus once the user selects a participant. The setTimeout defers
+ // focus past the RHP entry / picker close animation so the input reliably receives focus.
useEffect(() => {
- if (!shouldAutoFocusOnMount || isAmountFieldDisabled || !isNewManualExpenseFlowEnabled || isParticipantPickerVisible) {
+ if (!shouldAutoFocusOnMount || isAmountFieldDisabled || isParticipantPickerVisible) {
return;
}
@@ -130,7 +126,7 @@ function AmountField({
}
clearTimeout(focusTimeoutRef.current);
};
- }, [shouldAutoFocusOnMount, isAmountFieldDisabled, isNewManualExpenseFlowEnabled, isParticipantPickerVisible]);
+ }, [shouldAutoFocusOnMount, isAmountFieldDisabled, isParticipantPickerVisible]);
const showCurrencyPicker = () => {
setIsCurrencyPickerVisible(true);
@@ -299,7 +295,7 @@ function AmountField({
value={effectiveCurrency}
onInputChange={updateCurrency}
/>
- {isNewManualExpenseFlowEnabled && !isAmountFieldDisabled ? (
+ {!isAmountFieldDisabled ? (
- {isNewManualExpenseFlowEnabled && !isReadOnly ? (
+ {!isReadOnly ? (
- {!!senderWorkspace?.name && {senderWorkspace.name}}
{senderWorkspace?.name ? (
- {translate('workspace.common.workspace')}
+ <>
+ {senderWorkspace.name}
+ {translate('workspace.common.workspace')}
+ >
) : (
- {translate('workspace.common.workspace')}
+ {translate('workspace.common.workspace')}
)}
{isInteractive && (
diff --git a/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx b/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx
index ed953aabf5b9..3467995a3e18 100644
--- a/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx
+++ b/src/components/MoneyRequestConfirmationList/sections/MerchantField.tsx
@@ -31,7 +31,7 @@ type MerchantFieldProps = {
};
function MerchantField({isMerchantRequired, shouldDisplayFieldError, formError}: MerchantFieldProps) {
- const {action, iouType, transactionID, reportID, reportActionID, isReadOnly, didConfirm, isEditingSplitBill, isNewManualExpenseFlowEnabled} = useConfirmationFields();
+ const {action, iouType, transactionID, reportID, reportActionID, isReadOnly, didConfirm, isEditingSplitBill} = useConfirmationFields();
const styles = useThemeStyles();
const {translate} = useLocalize();
const {getCurrencyDecimals, getCurrencySymbol} = useCurrencyListActions();
@@ -111,7 +111,7 @@ function MerchantField({isMerchantRequired, shouldDisplayFieldError, formError}:
setMoneyRequestMerchant(transactionID, newMerchant, true, transactionHasReceipt);
};
- if (isNewManualExpenseFlowEnabled && !isReadOnly) {
+ if (!isReadOnly) {
return (
(null);
const [splitDraftTransaction] = useOnyx(`${ONYXKEYS.COLLECTION.SPLIT_TRANSACTION_DRAFT}${transactionID}`);
@@ -103,9 +103,6 @@ function TaxFields({policy, policyForMovingExpenses, iouCurrencyCode, canModifyT
};
useEffect(() => {
- if (!isNewManualExpenseFlowEnabled) {
- return;
- }
// Compare the numeric value rather than the formatted string. An in-progress edit such as "5.0" (or an
// empty field) represents the same stored amount as the re-padded "5.00", so it must not be overwritten
// while the user is typing. Only refresh the field when the stored tax amount genuinely differs (e.g. the
@@ -119,21 +116,21 @@ function TaxFields({policy, policyForMovingExpenses, iouCurrencyCode, canModifyT
}
numberFormRef.current?.updateNumber(taxAmountInput);
onTaxAmountEmptyChange?.(false);
- }, [isNewManualExpenseFlowEnabled, taxAmount, taxAmountInput, onTaxAmountEmptyChange]);
+ }, [taxAmount, taxAmountInput, onTaxAmountEmptyChange]);
useEffect(() => {
- if (isNewManualExpenseFlowEnabled && canModifyTaxFields) {
+ if (canModifyTaxFields) {
return () => onTaxAmountEmptyChange?.(false);
}
onTaxAmountEmptyChange?.(false);
- }, [isNewManualExpenseFlowEnabled, canModifyTaxFields, onTaxAmountEmptyChange]);
+ }, [canModifyTaxFields, onTaxAmountEmptyChange]);
useEffect(() => {
- if (!isNewManualExpenseFlowEnabled || formError !== 'iou.error.invalidTaxAmount' || taxAmount > maxTaxAmount) {
+ if (formError !== 'iou.error.invalidTaxAmount' || taxAmount > maxTaxAmount) {
return;
}
clearFormErrors(['iou.error.invalidTaxAmount']);
- }, [isNewManualExpenseFlowEnabled, formError, taxAmount, maxTaxAmount, clearFormErrors]);
+ }, [formError, taxAmount, maxTaxAmount, clearFormErrors]);
return (
<>
@@ -158,7 +155,7 @@ function TaxFields({policy, policyForMovingExpenses, iouCurrencyCode, canModifyT
errorText={shouldDisplayTaxRateError ? translate(formError as TranslationPaths) : ''}
sentryLabel={CONST.SENTRY_LABEL.REQUEST_CONFIRMATION_LIST.TAX_RATE_FIELD}
/>
- {isNewManualExpenseFlowEnabled && canModifyTaxFields ? (
+ {canModifyTaxFields ? (
@@ -135,7 +135,6 @@ function ClassificationFields({
shouldDisplayFieldError={errorState.shouldDisplayFieldError}
didConfirm={didConfirm}
isReadOnly={isReadOnly}
- isNewManualExpenseFlowEnabled={isNewManualExpenseFlowEnabled}
formError={errorState.formError}
transactionID={transactionID}
action={action}
diff --git a/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx b/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx
deleted file mode 100644
index a209e7fd0c03..000000000000
--- a/src/components/MoneyRequestReportView/ExternalScrollFlashListTable.tsx
+++ /dev/null
@@ -1,248 +0,0 @@
-import ScrollView from '@components/ScrollView';
-
-import type {FlashListRef, ListRenderItemInfo} from '@shopify/flash-list';
-import type {NativeScrollEvent, NativeSyntheticEvent, ScrollViewProps} from 'react-native';
-
-// Deliberately not the @components/FlashList wrapper: it doesn't type `ref` (this list needs one for layout reads),
-// and its only addition — composer scroll-event emission — would fire duplicates here, since this list's scroll events
-// are synthesized from the parent list's scroll, which already emits them.
-import {FlashList} from '@shopify/flash-list';
-import React, {useEffect, useImperativeHandle, useRef} from 'react';
-import {View} from 'react-native';
-
-/**
- * A tiny subscribe/notify store carrying the parent list's vertical scroll offset. It is fed by the parent's onScroll
- * WITHOUT React state, so the parent (and every sibling report action) never re-renders on scroll; the offset reaches
- * the driver, which turns it into a synthetic scroll event that updates only the nested FlashList's render stack.
- */
-type ScrollOffsetStore = {
- getOffset: () => number;
- setOffset: (offset: number) => void;
- subscribe: (listener: () => void) => () => void;
-};
-
-function createScrollOffsetStore(): ScrollOffsetStore {
- let offset = 0;
- const listeners = new Set<() => void>();
- return {
- getOffset: () => offset,
- setOffset: (next: number) => {
- if (next === offset) {
- return;
- }
- offset = next;
- for (const listener of listeners) {
- listener();
- }
- },
- subscribe: (listener: () => void) => {
- listeners.add(listener);
- return () => {
- listeners.delete(listener);
- };
- },
- };
-}
-
-// The subset of the ScrollView imperative surface FlashList actually drives — `getScrollableNode` is read internally
-// by RecyclerView for bound detection, and `scrollTo`/`scrollToEnd`/`flashScrollIndicators`/`getNativeScrollRef` back
-// FlashList's public ref. Naming it makes "the part of ScrollView the driver must honor" explicit rather than erased.
-type MinimalScrollRef = {
- scrollTo: () => void;
- scrollToEnd: () => void;
- flashScrollIndicators: () => void;
- getScrollableNode: () => View | null;
- getNativeScrollRef: () => View | null;
-};
-
-// `store` and `offsetTop` are injected at runtime by FlashList via `overrideProps`, never by FlashList's own typed
-// call site — declaring them optional makes the driver structurally a ScrollView component, so no cast is needed to
-// pass it as `renderScrollComponent`. FlashList's renderScrollComponent wrapper passes the ref as a prop, so the
-// driver takes `ref` directly (React 19 style) rather than via forwardRef.
-type ExternalScrollDriverProps = Omit & {
- /** Source of the parent list's vertical scroll offset. */
- store?: ScrollOffsetStore;
-
- /** Where the table region starts within the parent page's scrollable content (px from the top). */
- offsetTop?: number;
-
- /** Imperative handle FlashList drives (scrollTo/scrollToEnd/getScrollableNode…). */
- ref?: React.Ref;
-};
-
-/**
- * Replacement scroll container for the nested table FlashList. It does NOT scroll — it is a plain View that grows to
- * the full content height so the parent page scrolls through it — and it synthesizes FlashList's vertical scroll
- * events from the parent's offset. FlashList reads `getScrollableNode` internally and delegates its public
- * `scrollTo`/`scrollToEnd`/`flashScrollIndicators`/`getNativeScrollRef` to this ref; the scroll no-ops mean offset
- * corrections settle below the fold exactly like the parent-driven windowing. Must be a stable module-level component:
- * FlashList memoizes its scroll component on identity.
- */
-function ExternalScrollDriver({store, offsetTop = 0, onScroll, children, style, ref}: ExternalScrollDriverProps) {
- const nodeRef = useRef(null);
-
- useImperativeHandle(
- ref,
- () => ({
- scrollTo: () => {},
- scrollToEnd: () => {},
- flashScrollIndicators: () => {},
- getScrollableNode: () => nodeRef.current,
- getNativeScrollRef: () => nodeRef.current,
- }),
- [],
- );
-
- useEffect(() => {
- if (!store) {
- return;
- }
- const emit = () => {
- // Offset into the nested list's own coordinate space (FlashList subtracts its measured firstItemOffset —
- // the header height — internally). Only `contentOffset.y` is read for a vertical list, so nothing else is
- // populated; windowing comes from the `overrideWindowSize` prop, not this event.
- const y = Math.max(0, store.getOffset() - offsetTop);
- try {
- // Synthesizing a native scroll event requires an assertion: NativeSyntheticEvent's target/currentTarget
- // are RN HostInstances that can't be constructed in JS. FlashList's vertical handler reads only
- // contentOffset.y.
- // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
- onScroll?.({nativeEvent: {contentOffset: {x: 0, y}}} as NativeSyntheticEvent);
- } catch {
- // During back-navigation teardown FlashList can be driven after its layout manager is gone, which
- // throws ("LayoutManager is not initialized"). This call only feeds a synthetic scroll frame to
- // window the nested list, so a dropped frame here is harmless — swallow it rather than crash.
- }
- };
- // Seed the initial window, then track subsequent parent scrolls. Re-subscribes only when the store, the
- // measured offset, or FlashList's scroll handler change — none of which happen per scroll frame.
- emit();
- return store.subscribe(emit);
- }, [store, offsetTop, onScroll]);
-
- return (
-
- {children}
-
- );
-}
-
-type ExternalScrollFlashListTableHandle = {
- /** Page-space position of a row — where it sits within the parent's scrollable content. Derived from the nested
- * list's layout data, so it works for rows that aren't mounted. The table owns this math (offsetTop + its own
- * header height + the row's layout) so the parent never learns the nested coordinate system. */
- getRowPageOffset: (index: number) => {top: number; height: number} | undefined;
-};
-
-type ExternalScrollFlashListTableProps = {
- /** Rows to render. FlashList windows and recycles them against the parent's scroll offset. */
- items: T[];
-
- /** Stable key per row. */
- keyExtractor: (item: T, index: number) => string;
-
- /** Recycling bucket per row (transaction vs. group header) so FlashList reuses like with like. */
- getItemType: (item: T) => string;
-
- /** Renders a single row. */
- renderItem: (item: T, index: number, meta: {isFirst: boolean; isLast: boolean}) => React.ReactElement | null;
-
- /** Column header rendered above the rows and scrolled horizontally with them. */
- renderHeader: () => React.ReactElement | null;
-
- /** Estimated row height used before a row has been measured. */
- estimatedRowHeight: number;
-
- /** Full table width (wider than the viewport). Drives the horizontal scroll range. */
- contentWidth: number;
-
- /** Shared offset store fed by the parent's onScroll. */
- store: ScrollOffsetStore;
-
- /** Visible height of the parent viewport. */
- viewportHeight: number;
-
- /** Where the table region starts within the parent page's scrollable content (px from the top). */
- offsetTop: number;
-
- /** Imperative handle exposing row positions in page space (see ExternalScrollFlashListTableHandle). */
- ref?: React.Ref;
-};
-
-/**
- * A vertically-virtualized, horizontally-scrollable table built on FlashList instead of a hand-rolled virtualized list.
- *
- * The nested FlashList is fed a non-scrolling `ExternalScrollDriver` as its scroll container, so it grows to full
- * content height (the parent page scrolls through it) while FlashList still recycles rows against the parent's scroll
- * offset — via the patched `overrideWindowSize` prop, which lets FlashList treat the parent viewport as its window
- * instead of measuring its own (full-height) container. A single native horizontal ScrollView wraps the whole list, so
- * all rows share one smooth horizontal scroll and nothing outside it moves sideways.
- *
- * LAYOUT NOTE: FlashList's outer container defaults to `flex: 1` + `overflow: hidden` (a clipping viewport). We
- * override it via `style` below to grow to content height and not clip, since the page — not the list — owns vertical
- * scroll.
- */
-function ExternalScrollFlashListTable({
- items,
- keyExtractor,
- getItemType,
- renderItem,
- renderHeader,
- estimatedRowHeight,
- contentWidth,
- store,
- viewportHeight,
- offsetTop,
- ref,
-}: ExternalScrollFlashListTableProps) {
- const lastIndex = items.length - 1;
- const listRef = useRef>(null);
-
- useImperativeHandle(
- ref,
- () => ({
- getRowPageOffset: (index: number) => {
- const layout = listRef.current?.getLayout(index);
- if (!layout) {
- return undefined;
- }
- return {top: offsetTop + (listRef.current?.getFirstItemOffset() ?? 0) + layout.y, height: layout.height};
- },
- }),
- [offsetTop],
- );
-
- return (
-
-
- ref={listRef}
- data={items}
- keyExtractor={keyExtractor}
- getItemType={getItemType}
- renderItem={({item, index}: ListRenderItemInfo) => renderItem(item, index, {isFirst: index === 0, isLast: index === lastIndex})}
- ListHeaderComponent={renderHeader()}
- drawDistance={estimatedRowHeight * 12}
- renderScrollComponent={ExternalScrollDriver}
- // Consumed by ExternalScrollDriver (FlashList spreads overrideProps onto the scroll component).
- overrideProps={{store, offsetTop}}
- // Treat the parent viewport as the list's window instead of measuring the (full-height) driver View.
- overrideWindowSize={{width: contentWidth, height: viewportHeight}}
- // Grow to content height and don't clip — the parent page owns vertical scroll, so the list's own
- // clipping viewport must be neutralized.
- style={{width: contentWidth, flexGrow: 0, flexShrink: 0, flexBasis: 'auto', overflow: 'visible'}}
- scrollEnabled={false}
- />
-
- );
-}
-
-export default ExternalScrollFlashListTable;
-export {createScrollOffsetStore};
-export type {ExternalScrollFlashListTableHandle};
diff --git a/src/components/MoneyRequestReportView/ExternalScrollLegendListTable.tsx b/src/components/MoneyRequestReportView/ExternalScrollLegendListTable.tsx
new file mode 100644
index 000000000000..2dc60dfaab68
--- /dev/null
+++ b/src/components/MoneyRequestReportView/ExternalScrollLegendListTable.tsx
@@ -0,0 +1,280 @@
+import ScrollView from '@components/ScrollView';
+
+import type {LegendListRef, LegendListRenderItemProps} from '@legendapp/list/react-native';
+import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, ScrollViewProps} from 'react-native';
+
+import {LegendList} from '@legendapp/list/react-native';
+import React, {useEffect, useImperativeHandle, useRef} from 'react';
+import {View} from 'react-native';
+
+/**
+ * A tiny subscribe/notify store carrying the parent list's vertical scroll offset. It is fed by the parent's onScroll
+ * WITHOUT React state, so the parent (and every sibling report action) never re-renders on scroll; the offset reaches
+ * the driver, which turns it into a synthetic scroll event that updates only the nested LegendList's render window.
+ */
+type ScrollOffsetStore = {
+ getOffset: () => number;
+ setOffset: (offset: number) => void;
+ subscribe: (listener: () => void) => () => void;
+};
+
+function createScrollOffsetStore(): ScrollOffsetStore {
+ let offset = 0;
+ const listeners = new Set<() => void>();
+ return {
+ getOffset: () => offset,
+ setOffset: (next: number) => {
+ if (next === offset) {
+ return;
+ }
+ offset = next;
+ for (const listener of listeners) {
+ listener();
+ }
+ },
+ subscribe: (listener: () => void) => {
+ listeners.add(listener);
+ return () => {
+ listeners.delete(listener);
+ };
+ },
+ };
+}
+
+type MeasureCallback = (x: number, y: number, width: number, height: number, pageX: number, pageY: number) => void;
+
+// The ScrollView methods LegendList reads from its custom scroll component. The driver does not scroll itself, but it
+// must report the parent viewport during layout and provide the no-op scrolling methods used by LegendList internals.
+type MinimalScrollRef = {
+ measure: (callback: MeasureCallback) => void;
+ scrollTo: (options?: {x?: number; y?: number; animated?: boolean}) => void;
+ scrollToEnd: (options?: {animated?: boolean}) => void;
+ flashScrollIndicators: () => void;
+ getScrollableNode: () => View | null;
+ getNativeScrollRef: () => View | null;
+ getScrollResponder: () => null;
+ getCurrentScrollOffset: () => number;
+};
+
+type ExternalScrollDriverProps = Omit & {
+ /** Source of the parent list's vertical scroll offset. */
+ store: ScrollOffsetStore;
+
+ /** Where the table region starts within the parent page's scrollable content (px from the top). */
+ offsetTop: number;
+
+ /** Height LegendList must use for its virtualized viewport instead of the full-height driver View. */
+ viewportHeight: number;
+
+ /** Imperative handle LegendList drives. */
+ ref?: React.Ref;
+};
+
+/**
+ * Replacement scroll container for the nested table LegendList. It does not scroll. It is a plain View that grows to
+ * the full content height so the parent page scrolls through it. Its layout callbacks substitute the parent viewport
+ * height for the View's real height, giving LegendList a bounded virtualized window without a package patch.
+ */
+function ExternalScrollDriver({store, offsetTop, viewportHeight, onLayout, onScroll, children, style, testID, ref}: ExternalScrollDriverProps) {
+ const nodeRef = useRef(null);
+ const lastLayoutEventRef = useRef(null);
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ measure: (callback) => {
+ const node = nodeRef.current;
+ if (!node) {
+ callback(0, 0, 0, viewportHeight, 0, 0);
+ return;
+ }
+
+ node.measure((x, y, width, _height, pageX, pageY) => callback(x, y, width, viewportHeight, pageX, pageY));
+ },
+ scrollTo: () => {},
+ scrollToEnd: () => {},
+ flashScrollIndicators: () => {},
+ getScrollableNode: () => nodeRef.current,
+ getNativeScrollRef: () => nodeRef.current,
+ getScrollResponder: () => null,
+ getCurrentScrollOffset: () => getLocalScrollOffset(store, offsetTop),
+ }),
+ [offsetTop, store, viewportHeight],
+ );
+
+ useEffect(() => {
+ const emit = () => {
+ // NativeSyntheticEvent's host targets cannot be constructed in JavaScript. LegendList's vertical handler
+ // reads contentOffset.y and treats the event like a normal parent-driven scroll frame.
+ // eslint-disable-next-line @typescript-eslint/no-unsafe-type-assertion
+ onScroll?.({nativeEvent: {contentOffset: {x: 0, y: getLocalScrollOffset(store, offsetTop)}}} as NativeSyntheticEvent);
+ };
+ emit();
+ return store.subscribe(emit);
+ }, [offsetTop, onScroll, store]);
+
+ useEffect(() => {
+ const event = lastLayoutEventRef.current;
+ if (!event) {
+ return;
+ }
+
+ onLayout?.(getViewportLayoutEvent(event, viewportHeight));
+ }, [onLayout, viewportHeight]);
+
+ const handleLayout = (event: LayoutChangeEvent) => {
+ lastLayoutEventRef.current = event;
+ onLayout?.(getViewportLayoutEvent(event, viewportHeight));
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+function getLocalScrollOffset(store: ScrollOffsetStore, offsetTop: number): number {
+ return Math.max(0, store.getOffset() - offsetTop);
+}
+
+function getViewportLayoutEvent(event: LayoutChangeEvent, viewportHeight: number): LayoutChangeEvent {
+ return {
+ ...event,
+ nativeEvent: {
+ ...event.nativeEvent,
+ layout: {...event.nativeEvent.layout, height: viewportHeight},
+ },
+ };
+}
+
+type ExternalScrollLegendListTableHandle = {
+ /** Page-space position of a row — where it sits within the parent's scrollable content. Derived from the nested
+ * list's layout data, so it works for rows that aren't mounted. The table owns this math (offsetTop + its own
+ * header height + the row's layout) so the parent never learns the nested coordinate system. */
+ getRowPageOffset: (index: number) => {top: number; height: number} | undefined;
+};
+
+type ExternalScrollLegendListTableProps = {
+ /** Rows to render. LegendList windows them against the parent's scroll offset. */
+ items: T[];
+
+ /** Stable key per row. */
+ keyExtractor: (item: T, index: number) => string;
+
+ /** Item type per row, used for independent row-size estimates. */
+ getItemType: (item: T) => string;
+
+ /** Renders a single row. */
+ renderItem: (item: T, index: number, meta: {isFirst: boolean; isLast: boolean}) => React.ReactElement | null;
+
+ /** Column header rendered above the rows and scrolled horizontally with them. */
+ renderHeader: () => React.ReactElement | null;
+
+ /** Estimated row height used before a row has been measured. */
+ estimatedRowHeight: number;
+
+ /** Full table width (wider than the viewport). Drives the horizontal scroll range. */
+ contentWidth: number;
+
+ /** Shared offset store fed by the parent's onScroll. */
+ store: ScrollOffsetStore;
+
+ /** Visible height of the parent viewport. */
+ viewportHeight: number;
+
+ /** Where the table region starts within the parent page's scrollable content (px from the top). */
+ offsetTop: number;
+
+ /** Imperative handle exposing row positions in page space. */
+ ref?: React.Ref;
+};
+
+/**
+ * A vertically virtualized, horizontally scrollable table driven by its parent LegendList's vertical offset.
+ * The custom scroll driver grows to content height while reporting the parent's bounded viewport to LegendList. A
+ * single horizontal ScrollView keeps the column header and rows aligned without moving the chat below it.
+ */
+function ExternalScrollLegendListTable({
+ items,
+ keyExtractor,
+ getItemType,
+ renderItem,
+ renderHeader,
+ estimatedRowHeight,
+ contentWidth,
+ store,
+ viewportHeight,
+ offsetTop,
+ ref,
+}: ExternalScrollLegendListTableProps) {
+ const lastIndex = items.length - 1;
+ const listRef = useRef(null);
+ const headerSizeRef = useRef(0);
+
+ useImperativeHandle(
+ ref,
+ () => ({
+ getRowPageOffset: (index: number) => {
+ const state = listRef.current?.getState();
+ const rowTop = state?.positionAtIndex(index);
+ if (rowTop === undefined || !Number.isFinite(rowTop)) {
+ return undefined;
+ }
+ return {
+ top: offsetTop + headerSizeRef.current + rowTop,
+ height: state?.sizeAtIndex(index) ?? estimatedRowHeight,
+ };
+ },
+ }),
+ [estimatedRowHeight, offsetTop],
+ );
+
+ const renderScrollComponent = (scrollProps: ScrollViewProps) => (
+
+ );
+
+ return (
+
+
+ ref={listRef}
+ data={items}
+ keyExtractor={keyExtractor}
+ getItemType={getItemType}
+ renderItem={({item, index}: LegendListRenderItemProps) => renderItem(item, index, {isFirst: index === 0, isLast: index === lastIndex})}
+ extraData={renderItem}
+ ListHeaderComponent={renderHeader()}
+ drawDistance={estimatedRowHeight * 12}
+ estimatedItemSize={estimatedRowHeight}
+ estimatedListSize={{width: contentWidth, height: viewportHeight}}
+ renderScrollComponent={renderScrollComponent}
+ onMetricsChange={({headerSize}) => {
+ headerSizeRef.current = headerSize;
+ }}
+ // Grow to content height and don't clip — the parent page owns vertical scroll, so the list's own
+ // clipping viewport must be neutralized.
+ style={{width: contentWidth, flexGrow: 0, flexShrink: 0, flexBasis: 'auto', overflow: 'visible'}}
+ scrollEnabled={false}
+ />
+
+ );
+}
+
+export default ExternalScrollLegendListTable;
+export {createScrollOffsetStore};
+export type {ExternalScrollLegendListTableHandle};
diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx
index 1b8f09edaa03..72aacd5b03c7 100644
--- a/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx
+++ b/src/components/MoneyRequestReportView/MoneyRequestReportActionsList.tsx
@@ -1,5 +1,3 @@
-import ScrollView from '@components/ScrollView';
-
import useAppFocusEvent from '@hooks/useAppFocusEvent';
import useCurrentUserPersonalDetails from '@hooks/useCurrentUserPersonalDetails';
import {useIsReportLoadPending} from '@hooks/useInFlightRequests';
@@ -76,9 +74,8 @@ import isEmpty from 'lodash/isEmpty';
import React, {useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState} from 'react';
import {DeviceEventEmitter, View} from 'react-native';
+import MoneyRequestReportEmptyStateView from './MoneyRequestReportEmptyStateView';
import MoneyRequestReportTransactionList from './MoneyRequestReportTransactionList';
-import MoneyRequestViewReportFields from './MoneyRequestViewReportFields';
-import SearchMoneyRequestReportEmptyState from './SearchMoneyRequestReportEmptyState';
import SelectionToolbar from './SelectionToolbar';
/**
@@ -639,8 +636,12 @@ function MoneyRequestReportActionsList({onLayout}: MoneyRequestReportListProps)
hasNextActionMadeBySameActor(visibleReportActions, indexWithinReportActions, isOffline);
const shouldDisableContextMenuForConciergeDraft = isDraftPendingCompletion && draftReportActionID === reportAction.reportActionID;
+ // This value cannot be memoized, because it is based on the indexWithinReportActions which changes on every render.
+ // eslint-disable-next-line react/jsx-no-constructed-context-values
+ const reportActionIndexContextValue = {index: indexWithinReportActions, isNewest: indexWithinReportActions === visibleReportActions.length - 1};
+
return (
-
+
-
-
-
+
)}
{!isReportEmpty && !!reportStable && (
;
+
+ onLayout?: (event: LayoutChangeEvent) => void;
+};
+
+/** Rendered instead of the unified list when the report has no transactions and no comments. */
+function MoneyRequestReportEmptyStateView({report, policy, onLayout}: MoneyRequestReportEmptyStateViewProps) {
+ const styles = useThemeStyles();
+
+ return (
+
+
+
+
+ );
+}
+
+export default MoneyRequestReportEmptyStateView;
diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx
index 74597bda34dd..6b345ac9c619 100644
--- a/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx
+++ b/src/components/MoneyRequestReportView/MoneyRequestReportTransactionList.tsx
@@ -1,7 +1,6 @@
import LinkButton from '@components/ButtonComposed/composed/LinkButton';
import ButtonWithDropdownMenu from '@components/ButtonWithDropdownMenu';
import Checkbox from '@components/Checkbox';
-import type FlatListRefType from '@components/FlashList/types';
import OfflineWithFeedback from '@components/OfflineWithFeedback';
import DropdownButton from '@components/Search/FilterDropdowns/DropdownButton';
import {useSearchSelectionActions, useSearchSelectionContext} from '@components/Search/SearchContext';
@@ -63,6 +62,8 @@ import shouldShowTransactionYear from '@libs/TransactionUtils/shouldShowTransact
import isReportOpenInSuperWideRHP from '@navigation/helpers/isReportOpenInSuperWideRHP';
import Navigation from '@navigation/Navigation';
+import type ActionListRefType from '@pages/inbox/ActionListTypes';
+
import variables from '@styles/variables';
import CONST from '@src/CONST';
@@ -104,7 +105,7 @@ type TransactionListItemData = {type: 'section-header'; groupKey: string; group:
/**
* Bundle of data + JSX nodes the parent needs to render the unified list around the transaction-list state.
* Wide on purpose: this is the single integration point between TransactionList's internal state and the parent
- * FlatList that renders both transactions and report actions in one virtualized scroll. Splitting would just smear the
+ * list that renders both transactions and report actions in one virtualized scroll. Splitting would just smear the
* same locals across multiple call sites without earning an abstraction.
*/
type MoneyRequestReportTransactionListController = {
@@ -123,7 +124,7 @@ type MoneyRequestReportTransactionListController = {
/** Chrome rendered below the transaction items (pending placeholder, Add Expense, breakdown, total). Null when there are no transactions. */
afterListContent: React.ReactElement | null;
- /** True when the rendered table is wider than the viewport; the parent renders it via `ExternalScrollFlashListTable` with its own horizontal scroller. */
+ /** True when the rendered table is wider than the viewport; the parent renders it via `ExternalScrollLegendListTable` with its own horizontal scroller. */
shouldScrollHorizontally: boolean;
/** Pixel width of the table at full column visibility — passed to the horizontal scroll wrapper as `contentWidth`. */
@@ -138,7 +139,7 @@ const EMPTY_VIOLATIONS: OnyxTypes.TransactionViolations = [];
/**
* Looks up violations from the bulk collection and filters them via `getVisibleTransactionViolations`.
* Returns the stable EMPTY_VIOLATIONS reference for the common no-violations case so the row's prop
- * identity stays stable across FlashList recycles.
+ * identity stays stable across recycled list rows.
*/
function filterTransactionViolations(
transaction: TransactionWithOptionalHighlight,
@@ -202,8 +203,8 @@ type MoneyRequestReportTransactionListProps = {
/** Report action ID the unified list should initially scroll to, when deep-linked. */
linkedReportActionID: string | undefined;
- /** Ref forwarded to the underlying FlashList. */
- listRef: FlatListRefType;
+ /** Ref forwarded to the underlying action list. */
+ listRef: ActionListRefType;
/** Reports the unified list's last item index so the parent can jump to the bottom via scrollToIndex. */
onLastItemIndexChange?: (index: number) => void;
@@ -211,28 +212,28 @@ type MoneyRequestReportTransactionListProps = {
/** Accessibility label for the unified list. */
accessibilityLabel: string;
- /** FlashList onLayout callback (distinct from the empty-state `onLayout` above). */
+ /** Action list onLayout callback (distinct from the empty-state `onLayout` above). */
onListLayout: () => void;
- /** FlashList onScroll callback. */
+ /** Action list onScroll callback. */
onScroll: (event: NativeSyntheticEvent) => void;
- /** FlashList onScrollBeginDrag callback. */
+ /** Action list onScrollBeginDrag callback. */
onScrollBeginDrag: () => void;
- /** FlashList onContentSizeChange callback. */
+ /** Action list onContentSizeChange callback. */
onContentSizeChange: () => void;
- /** FlashList onViewableItemsChanged callback. */
+ /** Action list onViewableItemsChanged callback. */
onViewableItemsChanged: (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => void;
- /** FlashList onEndReached callback. */
+ /** Action list onEndReached callback. */
onEndReached: () => void;
- /** FlashList onStartReached callback. */
+ /** Action list onStartReached callback. */
onStartReached: () => void;
- /** FlashList contentContainerStyle. */
+ /** Action list contentContainerStyle. */
contentContainerStyle: StyleProp;
/** Whether the initial report actions are still loading. */
diff --git a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx
index d74572515164..e52d4cc7face 100644
--- a/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx
+++ b/src/components/MoneyRequestReportView/MoneyRequestReportUnifiedList.tsx
@@ -1,26 +1,26 @@
-import FlashList from '@components/FlashList';
-import type FlatListRefType from '@components/FlashList/types';
-
import useWindowDimensions from '@hooks/useWindowDimensions';
+import type ActionListRefType from '@pages/inbox/ActionListTypes';
+
import variables from '@styles/variables';
import type * as OnyxTypes from '@src/types/onyx';
-import type {FlashListProps, ListRenderItemInfo} from '@shopify/flash-list';
+import type {LegendListRef, LegendListRenderItemProps, ViewToken as LegendListViewToken} from '@legendapp/list/react-native';
import type {LayoutChangeEvent, NativeScrollEvent, NativeSyntheticEvent, StyleProp, ViewStyle, ViewToken} from 'react-native';
-import React, {memo, useEffect, useRef, useState} from 'react';
+import {LegendList} from '@legendapp/list/react-native';
+import React, {useEffect, useRef, useState} from 'react';
import {View} from 'react-native';
-import type {ExternalScrollFlashListTableHandle} from './ExternalScrollFlashListTable';
+import type {ExternalScrollLegendListTableHandle} from './ExternalScrollLegendListTable';
import type {MoneyRequestReportTransactionListController, TransactionListItemData} from './MoneyRequestReportTransactionList';
-import ExternalScrollFlashListTable, {createScrollOffsetStore} from './ExternalScrollFlashListTable';
+import ExternalScrollLegendListTable, {createScrollOffsetStore} from './ExternalScrollLegendListTable';
import MoneyRequestViewReportFields from './MoneyRequestViewReportFields';
import ReportActionsListLoadingSkeleton from './ReportActionsListLoadingSkeleton';
-/** Single virtualized data item rendered by the unified FlatList. Mixes transactions, a footer marker, and report actions in one scroll. */
+/** Single virtualized data item rendered by the unified list. Mixes transactions, a footer marker, and report actions in one scroll. */
type UnifiedListItem = TransactionListItemData | {readonly type: 'transactions-footer'} | {readonly type: 'report-action'; readonly action: OnyxTypes.ReportAction};
const TRANSACTIONS_FOOTER_ITEM: UnifiedListItem = {type: 'transactions-footer'};
@@ -44,26 +44,6 @@ function unifiedListItemType(item: UnifiedListItem) {
return item.type === 'report-action' ? item.action.actionName : item.type;
}
-type MoneyRequestReportFlashListProps = FlashListProps & {
- /** Ref to the underlying list, shared via the ActionList context (typed for the legacy FlatList). */
- ref: FlatListRefType;
-};
-
-/**
- * Forwards the shared ActionList context ref to the underlying FlashList. That context slot predates this FlashList-based
- * list and is still shared with the legacy report list, so it is typed for a FlatList. Mirroring InvertedFlashList, the
- * ref is forwarded through @components/FlashList — which receives it as an untyped runtime prop — so no type assertion is
- * needed. The scroll manager relies on the FlashList registering into this slot.
- */
-function MoneyRequestReportFlashList(props: MoneyRequestReportFlashListProps) {
- return (
-
- // thin forwarder; spreading the props (including the ref) is the point
- {...props}
- />
- );
-}
-
type MoneyRequestReportUnifiedListProps = {
/** Controller that owns the transaction rows and their selection/long-press state. */
controller: MoneyRequestReportTransactionListController;
@@ -90,7 +70,7 @@ type MoneyRequestReportUnifiedListProps = {
newTransactionID?: string;
/** Ref to the underlying list, shared via the ActionList context. */
- listRef: FlatListRefType;
+ listRef: ActionListRefType;
accessibilityLabel: string;
@@ -152,8 +132,8 @@ function MoneyRequestReportUnifiedList({
listFooterComponent,
}: MoneyRequestReportUnifiedListProps) {
// When the table is wider than the viewport it can't share the horizontally-scrolled container with the chat (chat
- // would drift sideways / jump on web). Instead the FlashList keeps ONLY the report actions virtualized, and the table is
- // rendered as the list header via ExternalScrollFlashListTable — a nested FlashList in its own single native
+ // would drift sideways / jump on web). Instead the LegendList keeps ONLY the report actions virtualized, and the table is
+ // rendered as the list header via ExternalScrollLegendListTable, a nested LegendList in its own single native
// horizontal scroller that windows its rows against THIS list's vertical scroll offset. Chat never lives inside a
// horizontal scroller, so it never moves sideways. Everywhere else the transactions stay virtualized inline with
// the report actions.
@@ -180,10 +160,10 @@ function MoneyRequestReportUnifiedList({
const reportActionIndexOffset = shouldInlineTransactions ? controller.transactionListItems.length + 1 : 0;
// Latest viewable items, kept current from onViewableItemsChanged, so the new-transaction scroll can skip when the row is already on screen.
- const viewableItemsRef = useRef([]);
+ const viewableItemsRef = useRef>>([]);
// Handle to the nested table (horizontal mode) — read for row page positions, never driven to scroll (its scroll is a no-op).
- const tableRef = useRef(null);
+ const tableRef = useRef(null);
// Viewport height + table offset fed to the nested table so it can window its rows against this list's scroll.
// tableOffsetTop is the height of everything above the table region (the report-fields header).
@@ -193,7 +173,7 @@ function MoneyRequestReportUnifiedList({
const [viewportHeight, setViewportHeight] = useState(windowHeight);
const [tableOffsetTop, setTableOffsetTop] = useState(0);
- // A subscribe/notify store carries the scroll offset to the nested table FlashList with zero parent re-renders.
+ // A subscribe/notify store carries the scroll offset to the nested table LegendList with zero parent re-renders.
// Lazy useState initializer (not useRef.current) so it is created exactly once without reading a ref during render.
const [scrollOffsetStore] = useState(createScrollOffsetStore);
@@ -204,7 +184,7 @@ function MoneyRequestReportUnifiedList({
}, [report.reportID, scrollOffsetStore]);
const handleScroll = (event: NativeSyntheticEvent) => {
- // Always feed the offset store (emitter, not state: the nested FlashList updates its own render stack without
+ // Always feed the offset store (emitter, not state: the nested LegendList updates its own render window without
// re-rendering the parent). Feed it even in inline mode — the store has no subscribers then, so this is a cheap
// write — so that if the layout flips to the horizontal table, the nested list windows against the real scroll
// offset instead of a stale 0.
@@ -218,9 +198,9 @@ function MoneyRequestReportUnifiedList({
};
// The hook compares unreadMarkerReportActionIndex (0-based within visibleReportActions) against
- // raw FlashList indices. When transactions are present, report actions start at reportActionIndexOffset,
+ // raw LegendList indices. When transactions are present, report actions start at reportActionIndexOffset,
// so we shift all viewable indices down before forwarding so the comparison is apples-to-apples.
- const onViewableItemsChangedAdjusted = (info: {viewableItems: ViewToken[]; changed: ViewToken[]}) => {
+ const onViewableItemsChangedAdjusted = (info: {viewableItems: Array>; changed: Array>}) => {
// Keep the raw array so the new-transaction effect can tell whether the new row is already on screen.
viewableItemsRef.current = info.viewableItems;
if (reportActionIndexOffset === 0) {
@@ -233,7 +213,9 @@ function MoneyRequestReportUnifiedList({
});
};
- const dispatchRenderItem = ({item, index}: ListRenderItemInfo) => {
+ const listExtraData = {reportActionsExtraData, renderReportAction, renderTransactionListItem: controller.renderTransactionListItem, afterListContent: controller.afterListContent};
+
+ const dispatchRenderItem = ({item, index}: LegendListRenderItemProps) => {
switch (item.type) {
case 'section-header':
case 'transaction':
@@ -250,7 +232,7 @@ function MoneyRequestReportUnifiedList({
const linkedActionLocalIndex = linkedReportActionID ? visibleReportActions.findIndex((action) => action.reportActionID === linkedReportActionID) : -1;
const initialScrollIndex = linkedActionLocalIndex >= 0 ? linkedActionLocalIndex + reportActionIndexOffset : undefined;
- // FlashList's `initialScrollIndex` is captured once at mount. On a cold deep-link open the linked action is
+ // LegendList's `initialScrollIndex` is captured once at mount. On a cold deep-link open the linked action is
// often not in `visibleReportActions` yet (it paginates in after mount), so the mount-only hint resolves to
// undefined and the list never anchors on the linked message. Re-anchor imperatively once the linked action is
// present. Guarded so it fires exactly once per linked target and never yanks the user after they've scrolled.
@@ -302,7 +284,7 @@ function MoneyRequestReportUnifiedList({
return () => cancelAnimationFrame(rafId);
}
- // Horizontal table: the rows live in a nested FlashList whose own scroll is a no-op — only the parent page
+ // Horizontal table: the rows live in a nested LegendList whose own scroll is a no-op. Only the parent page
// scrolls. Ask the table where the row sits in page space (works for unmounted rows) and scroll the parent there.
const rafId = requestAnimationFrame(() => {
scrolledToNewTransactionIDRef.current = newTransactionID;
@@ -326,18 +308,27 @@ function MoneyRequestReportUnifiedList({
/>
);
+ const setListRef = (instance: LegendListRef | null) => {
+ const targetListRef = listRef;
+ if (!targetListRef) {
+ return;
+ }
+ targetListRef.current = instance;
+ };
+
return (
-
+ ref={setListRef}
accessibilityLabel={accessibilityLabel}
testID="money-request-report-actions-list"
data={data}
- extraData={reportActionsExtraData}
+ extraData={listExtraData}
renderItem={dispatchRenderItem}
keyExtractor={unifiedListKeyExtractor}
getItemType={unifiedListItemType}
initialScrollIndex={initialScrollIndex}
- maintainVisibleContentPosition={{autoscrollToBottomThreshold: undefined}}
+ maintainVisibleContentPosition
+ recycleItems
onViewableItemsChanged={onViewableItemsChangedAdjusted}
onLayout={handleLayout}
onEndReached={onEndReached}
@@ -353,7 +344,7 @@ function MoneyRequestReportUnifiedList({
{reportFieldsHeader}
{controller.beforeListContent}
-
+
items={controller.transactionListItems}
keyExtractor={unifiedListKeyExtractor}
getItemType={unifiedListItemType}
@@ -394,4 +385,4 @@ function MoneyRequestReportUnifiedList({
);
}
-export default memo(MoneyRequestReportUnifiedList);
+export default MoneyRequestReportUnifiedList;
diff --git a/src/components/Navigation/QuickCreationActionsBar/index.tsx b/src/components/Navigation/QuickCreationActionsBar/index.tsx
index aa8e3d7c8467..8fa2e74b75c4 100644
--- a/src/components/Navigation/QuickCreationActionsBar/index.tsx
+++ b/src/components/Navigation/QuickCreationActionsBar/index.tsx
@@ -20,7 +20,6 @@ import createDynamicRoute from '@libs/Navigation/helpers/dynamicRoutesUtils/crea
import getCreateReportRoute, {getReportsRootRoute, navigateToCreateReportWorkspaceSelection} from '@libs/Navigation/helpers/getCreateReportRoute';
import Navigation from '@libs/Navigation/Navigation';
import {openTravelDotLink} from '@libs/openTravelDotLink';
-import Permissions from '@libs/Permissions';
import {getDefaultChatEnabledPolicySelection, hasAcceptedTravelTerms, isPaidGroupPolicy, isPolicyAccessible, isWorkspaceProvisionedForTravel} from '@libs/PolicyUtils';
import {generateReportID, hasViolations as hasViolationsReportUtils} from '@libs/ReportUtils';
import {shouldRestrictUserBillableActions} from '@libs/SubscriptionUtils';
@@ -84,7 +83,7 @@ function QuickCreationActionsBar() {
const shouldShowBookTravel = !!travelEnabledPolicy;
- const isBlockedFromSpotnanaTravel = Permissions.isBetaEnabled(CONST.BETAS.PREVENT_SPOTNANA_TRAVEL, allBetas);
+ const isBlockedFromSpotnanaTravel = isBetaEnabled(CONST.BETAS.PREVENT_SPOTNANA_TRAVEL);
const primaryContactMethod = primaryLogin ?? session?.email ?? '';
const isTravelReady = useMemo(() => {
if (!!isBlockedFromSpotnanaTravel || !primaryContactMethod || Str.isSMSLogin(primaryContactMethod) || !isPaidGroupPolicy(travelEnabledPolicy)) {
diff --git a/src/components/NumericEditingController/hooks/useNumericEditingController.ts b/src/components/NumericEditingController/hooks/useNumericEditingController.ts
new file mode 100644
index 000000000000..f18a370956d7
--- /dev/null
+++ b/src/components/NumericEditingController/hooks/useNumericEditingController.ts
@@ -0,0 +1,127 @@
+import {normalizeNumericInput} from '@components/NumericEditingController/utils';
+
+import useLocalize from '@hooks/useLocalize';
+
+import {replaceAllDigits, stripDecimalsFromAmount, validateAmount} from '@libs/MoneyRequestUtils';
+
+import {useEffect, useEffectEvent, useLayoutEffect, useRef, useState} from 'react';
+
+import useNumericSelection from './useNumericSelection';
+
+type UseNumericEditingControllerParams = {
+ value?: string;
+
+ onInputChange?: (value: string) => void;
+
+ allowNegative?: boolean;
+
+ /** Number of decimal places accepted by the controller. */
+ decimals?: number;
+
+ /** Maximum number of integer digits accepted by the controller. */
+ maxLength?: number;
+};
+
+/** Runs on mount and whenever `decimals` changes, sanitizing values that exceed the new precision. */
+function useDecimalsChangeEffect(decimals: number, sanitizeForDecimals: (decimals: number) => void) {
+ const previousDecimals = useRef(undefined);
+ const sanitizeForDecimalsEvent = useEffectEvent(sanitizeForDecimals);
+
+ useEffect(() => {
+ if (previousDecimals.current === decimals) {
+ return;
+ }
+
+ previousDecimals.current = decimals;
+ sanitizeForDecimalsEvent(decimals);
+ }, [decimals]);
+}
+
+/** Owns numeric value, formatting, validation, and commits while delegating caret state to `useNumericSelection`. */
+function useNumericEditingController({value: externalValueProp, onInputChange, allowNegative = false, decimals = 0, maxLength}: UseNumericEditingControllerParams) {
+ const {fromLocaleDigit, toLocaleDigit} = useLocalize();
+
+ const externalValue = externalValueProp ?? '';
+
+ const [currentValue, setCurrentValue] = useState(externalValue);
+ const [previousExternalValue, setPreviousExternalValue] = useState(externalValue);
+
+ // Keep the latest committed value available across batched state updates.
+ const committedValueRef = useRef(externalValue);
+
+ const formattedNumber = replaceAllDigits(currentValue, toLocaleDigit);
+
+ const {selection, collapse, reset, syncToEnd, syncAfterEdit, handleKeyPress, rejectEdit, handleNativeSelectionChange} = useNumericSelection({displayText: formattedNumber});
+
+ // Reset when the external value is cleared. Ignore other external changes while editing.
+ if (previousExternalValue !== externalValue) {
+ setPreviousExternalValue(externalValue);
+ if (externalValue === '') {
+ setCurrentValue('');
+ reset();
+ }
+ }
+
+ // Commits a canonical value and returns the previously committed value.
+ const applyValue = (nextValue: string, {notify = true}: {notify?: boolean} = {}) => {
+ const previousValue = committedValueRef.current;
+
+ committedValueRef.current = nextValue;
+ setCurrentValue(nextValue);
+
+ if (notify && nextValue !== previousValue) {
+ onInputChange?.(nextValue);
+ }
+
+ return previousValue;
+ };
+
+ const setNumber = (inputValue: string) => {
+ const numberWithLeadingZero = normalizeNumericInput(inputValue, {fromLocaleDigit, allowNegative});
+
+ if (!validateAmount(numberWithLeadingZero, decimals, maxLength, allowNegative)) {
+ rejectEdit();
+ return;
+ }
+
+ const previousValue = applyValue(numberWithLeadingZero);
+
+ syncAfterEdit({previousText: previousValue, nextText: numberWithLeadingZero});
+ };
+
+ // Replaces the canonical value without validation or notification and moves the caret to the end.
+ const updateNumber = (newNumber: string) => {
+ applyValue(newNumber, {notify: false});
+ syncToEnd(newNumber);
+ };
+
+ const getNumber = () => committedValueRef.current;
+
+ useLayoutEffect(() => {
+ // Keep the ref in sync with external resets, which bypass applyValue.
+ committedValueRef.current = currentValue;
+ }, [currentValue]);
+
+ useDecimalsChangeEffect(decimals, (newDecimals) => {
+ // Empty values and values already valid at the new precision need no update.
+ if (externalValue === '' || validateAmount(currentValue, newDecimals, maxLength, allowNegative)) {
+ return;
+ }
+
+ setNumber(stripDecimalsFromAmount(currentValue));
+ });
+
+ return {
+ value: currentValue,
+ formattedNumber,
+ selection,
+ setNumber,
+ updateNumber,
+ getNumber,
+ clearSelection: collapse,
+ handleSelectionChange: handleNativeSelectionChange,
+ handleKeyPress,
+ };
+}
+
+export default useNumericEditingController;
diff --git a/src/components/NumericEditingController/hooks/useNumericSelection.ts b/src/components/NumericEditingController/hooks/useNumericSelection.ts
new file mode 100644
index 000000000000..700eddfa49a0
--- /dev/null
+++ b/src/components/NumericEditingController/hooks/useNumericSelection.ts
@@ -0,0 +1,148 @@
+import type {NumericEditingKeyPressEvent, NumericEditingSelection} from '@components/NumericEditingController/types';
+import {clampSelection, collapseSelection, getSelectionAfterEdit, getSelectionAtOffset, isForwardDeleteKeyPress} from '@components/NumericEditingController/utils';
+
+import shouldIgnoreSelectionWhenUpdatedManually from '@libs/shouldIgnoreSelectionWhenUpdatedManually';
+
+import {useIsFocused} from '@react-navigation/native';
+import {useLayoutEffect, useRef, useState} from 'react';
+
+type UseNumericSelectionParams = {
+ /** Displayed text used to clamp native selections. */
+ displayText: string;
+};
+
+type NumericSelectionEdit = {
+ previousText: string;
+
+ nextText: string;
+};
+
+/** Manages the caret and guards against stale native selection events. */
+function useNumericSelection({displayText}: UseNumericSelectionParams) {
+ const isFocused = useIsFocused();
+
+ const [selection, setSelection] = useState(() => getSelectionAtOffset(displayText.length));
+ const [previousIsFocused, setPreviousIsFocused] = useState(isFocused);
+
+ // Native events can arrive before the pending text renders, so its length is kept to clamp them.
+ const pendingDisplayLengthRef = useRef(undefined);
+ // Native echoes one stale event after a controlled selection update. Consume it once. Only ever armed on the
+ // platforms that echo, so no platform check is needed where it is read.
+ const willSelectionBeUpdatedManually = useRef(false);
+ // Ignore the selection event generated by rejected input.
+ const willSelectionBeRestoredAfterInvalidInput = useRef(false);
+ // Forward-delete removes the next character, so the caret offset stays.
+ const forwardDeletePressedRef = useRef(false);
+
+ const collapse = () => {
+ setSelection(collapseSelection);
+ };
+
+ // Reset selection when returning to the screen.
+ if (previousIsFocused !== isFocused) {
+ setPreviousIsFocused(isFocused);
+ if (isFocused) {
+ collapse();
+ }
+ }
+
+ // Prepare guards for a manual selection update.
+ const prepareSelectionUpdate = (nextText: string) => {
+ willSelectionBeUpdatedManually.current = shouldIgnoreSelectionWhenUpdatedManually;
+ pendingDisplayLengthRef.current = nextText.length;
+ };
+
+ // Moves the caret to the start after an external clear.
+ const reset = () => {
+ setSelection(getSelectionAtOffset(0));
+ };
+
+ const syncToEnd = (nextText: string) => {
+ const offset = nextText.length;
+
+ if (displayText.length === offset && selection.start === offset && selection.end === offset) {
+ return;
+ }
+
+ prepareSelectionUpdate(nextText);
+ setSelection(getSelectionAtOffset(offset));
+ };
+
+ // Tracks whether the next edit follows a forward-delete key press.
+ const handleKeyPress = (event: NumericEditingKeyPressEvent) => {
+ forwardDeletePressedRef.current = isForwardDeleteKeyPress(event);
+ };
+
+ // Restores the last valid caret after a rejected edit.
+ const rejectEdit = () => {
+ // React Native syncs against its last reported position, so ignore this event.
+ willSelectionBeRestoredAfterInvalidInput.current = true;
+ // Shallow copy forces native selection reset: https://github.com/Expensify/App/issues/16385
+ setSelection((currentSelection) => ({...currentSelection}));
+ };
+
+ // Adjusts the caret for a text-length change.
+ const syncAfterEdit = ({previousText, nextText}: NumericSelectionEdit) => {
+ const wasForwardDeleteKeyPressed = forwardDeletePressedRef.current;
+ forwardDeletePressedRef.current = false;
+
+ if (previousText === nextText) {
+ rejectEdit();
+ return;
+ }
+
+ prepareSelectionUpdate(nextText);
+ setSelection((currentSelection) => getSelectionAfterEdit(currentSelection, previousText, nextText, wasForwardDeleteKeyPressed));
+ };
+
+ const handleNativeSelectionChange = (selectionStart: number, selectionEnd: number) => {
+ if (willSelectionBeRestoredAfterInvalidInput.current) {
+ willSelectionBeRestoredAfterInvalidInput.current = false;
+ return;
+ }
+
+ if (willSelectionBeUpdatedManually.current) {
+ willSelectionBeUpdatedManually.current = false;
+ return;
+ }
+
+ // iOS may report selection before the text renders, so use pending bounds.
+ const maxSelection = pendingDisplayLengthRef.current ?? displayText.length;
+ pendingDisplayLengthRef.current = undefined;
+ const reportedSelection = clampSelection({start: selectionStart, end: selectionEnd}, maxSelection);
+
+ setSelection((currentSelection) =>
+ // The input already holds this selection, so keep the current object and skip the render it would cost.
+ currentSelection.start === reportedSelection.start && currentSelection.end === reportedSelection.end ? currentSelection : reportedSelection,
+ );
+ };
+
+ useLayoutEffect(() => {
+ // Clear the rejected-input guard after the selection commits. Keep the manual-update guard until its native
+ // echo is handled, because that event can arrive after the commit.
+ willSelectionBeRestoredAfterInvalidInput.current = false;
+ }, [selection]);
+
+ useLayoutEffect(() => {
+ if (!isFocused) {
+ return;
+ }
+
+ pendingDisplayLengthRef.current = undefined;
+ willSelectionBeUpdatedManually.current = false;
+ willSelectionBeRestoredAfterInvalidInput.current = false;
+ }, [isFocused]);
+
+ return {
+ selection,
+ collapse,
+ reset,
+ syncToEnd,
+ syncAfterEdit,
+ handleKeyPress,
+ rejectEdit,
+ handleNativeSelectionChange,
+ };
+}
+
+export default useNumericSelection;
diff --git a/src/components/NumericEditingController/index.ts b/src/components/NumericEditingController/index.ts
new file mode 100644
index 000000000000..9c1484cf701f
--- /dev/null
+++ b/src/components/NumericEditingController/index.ts
@@ -0,0 +1,2 @@
+export {default as useNumericEditingController} from './hooks/useNumericEditingController';
+export type {NumericEditingKeyPressEvent, NumericEditingRef} from './types';
diff --git a/src/components/NumericEditingController/types.ts b/src/components/NumericEditingController/types.ts
new file mode 100644
index 000000000000..1eec2c742efb
--- /dev/null
+++ b/src/components/NumericEditingController/types.ts
@@ -0,0 +1,19 @@
+type NumericEditingRef = {
+ clearSelection: () => void;
+ updateNumber: (newNumber: string) => void;
+ getNumber: () => string;
+};
+
+type NumericEditingKeyPressEvent = {
+ nativeEvent: {
+ key: string;
+ ctrlKey?: boolean;
+ };
+};
+
+type NumericEditingSelection = {
+ start: number;
+ end: number;
+};
+
+export type {NumericEditingKeyPressEvent, NumericEditingRef, NumericEditingSelection};
diff --git a/src/components/NumericEditingController/utils.ts b/src/components/NumericEditingController/utils.ts
new file mode 100644
index 000000000000..2eb81f7a2795
--- /dev/null
+++ b/src/components/NumericEditingController/utils.ts
@@ -0,0 +1,68 @@
+import {isMobileSafari} from '@libs/Browser';
+import getOperatingSystem from '@libs/getOperatingSystem';
+import {addLeadingZero, replaceAllDigits, replaceCommasWithPeriod, stripCommaFromAmount, stripSpacesFromAmount} from '@libs/MoneyRequestUtils';
+
+import CONST from '@src/CONST';
+
+import type {NumericEditingKeyPressEvent, NumericEditingSelection} from './types';
+
+type NormalizeNumericInputOptions = {
+ fromLocaleDigit: (digit: string) => string;
+
+ allowNegative?: boolean;
+};
+
+/** Normalizes localized input to the canonical form expected by `validateAmount`, without validating it. */
+function normalizeNumericInput(inputValue: string, {fromLocaleDigit, allowNegative = false}: NormalizeNumericInputOptions): string {
+ // Remove spaces iOS Safari adds when pasting: https://github.com/Expensify/App/issues/16974
+ const inputWithoutSpaces = stripSpacesFromAmount(inputValue);
+ const inputWithCanonicalDigits = replaceAllDigits(inputWithoutSpaces, fromLocaleDigit);
+ const inputWithPeriodSeparator = inputWithCanonicalDigits.includes('.') ? stripCommaFromAmount(inputWithCanonicalDigits) : replaceCommasWithPeriod(inputWithCanonicalDigits);
+
+ return addLeadingZero(inputWithPeriodSeparator, allowNegative);
+}
+
+function isForwardDeleteKeyPress(event: NumericEditingKeyPressEvent): boolean {
+ const key = event.nativeEvent.key.toLowerCase();
+
+ if (isMobileSafari() && key === CONST.PLATFORM_SPECIFIC_KEYS.CTRL.DEFAULT) {
+ // Handle the Mac Accessibility keyboard shortcut on iOS Safari.
+ return true;
+ }
+
+ const operatingSystem = getOperatingSystem();
+ const isMacOrIOS = operatingSystem === CONST.OS.MAC_OS || operatingSystem === CONST.OS.IOS;
+
+ // Control-D is the macOS/iOS hardware keyboard shortcut.
+ return key === 'delete' || (isMacOrIOS && !!event.nativeEvent.ctrlKey && key === 'd');
+}
+
+function clampOffset(offset: number, maxLength: number): number {
+ return Math.min(Math.max(offset, 0), maxLength);
+}
+
+function getSelectionAtOffset(offset: number): NumericEditingSelection {
+ return {start: offset, end: offset};
+}
+
+function getSelectionAfterEdit(selection: NumericEditingSelection, previousText: string, nextText: string, wasForwardDeleteKeyPressed: boolean): NumericEditingSelection {
+ const isCollapsed = selection.start === selection.end;
+ const isDeletion = nextText.length < previousText.length;
+ const isForwardDelete = wasForwardDeleteKeyPressed && isCollapsed && isDeletion && nextText.startsWith(previousText.slice(0, selection.start));
+ const offset = isForwardDelete ? selection.end : selection.end + (nextText.length - previousText.length);
+
+ return getSelectionAtOffset(clampOffset(offset, nextText.length));
+}
+
+function collapseSelection(selection: NumericEditingSelection): NumericEditingSelection {
+ return getSelectionAtOffset(selection.end);
+}
+
+function clampSelection(selection: NumericEditingSelection, maxLength: number): NumericEditingSelection {
+ return {
+ start: clampOffset(selection.start, maxLength),
+ end: clampOffset(selection.end, maxLength),
+ };
+}
+
+export {clampSelection, collapseSelection, getSelectionAfterEdit, getSelectionAtOffset, isForwardDeleteKeyPress, normalizeNumericInput};
diff --git a/src/components/NumericField/NumericField.tsx b/src/components/NumericField/NumericField.tsx
new file mode 100644
index 000000000000..56472cda1602
--- /dev/null
+++ b/src/components/NumericField/NumericField.tsx
@@ -0,0 +1,68 @@
+import {useNumericEditingController} from '@components/NumericEditingController';
+import type {NumericEditingRef} from '@components/NumericEditingController';
+
+import type {ForwardedRef, ReactNode} from 'react';
+
+import {useImperativeHandle} from 'react';
+
+import type {NumericFieldActionsContextValue, NumericFieldStateContextValue} from './context/types';
+
+import {NumericFieldActionsContext, NumericFieldStateContext} from './context';
+
+type NumericFieldProps = {
+ /** Canonical value shared by composed primitives; only an empty value resets editing state. */
+ value?: string;
+
+ /** Called with the canonical value whenever the user commits an edit. */
+ onInputChange?: (value: string) => void;
+
+ /** Whether negative values are allowed; the canonical value always stores its sign. */
+ allowNegative?: boolean;
+
+ /** Number of decimal places accepted by the form. */
+ decimals?: number;
+
+ /** Maximum number of integer digits accepted by the form. */
+ maxLength?: number;
+
+ /** Error supplied by FormProvider and rendered by the text input. */
+ errorText?: string;
+
+ /** Ref exposing the number editing imperative API. */
+ ref?: ForwardedRef;
+
+ /** Composed primitives that consume NumericField state and actions through context. */
+ children: ReactNode;
+};
+
+function NumericField({value = '', onInputChange, allowNegative = false, decimals = 0, maxLength, errorText, ref, children}: NumericFieldProps) {
+ const controller = useNumericEditingController({value, onInputChange, allowNegative, decimals, maxLength});
+
+ useImperativeHandle(ref, () => ({
+ clearSelection: controller.clearSelection,
+ getNumber: controller.getNumber,
+ updateNumber: controller.updateNumber,
+ }));
+
+ const stateContextValue: NumericFieldStateContextValue = {
+ value: controller.value,
+ formattedNumber: controller.formattedNumber,
+ selection: controller.selection,
+ allowNegative,
+ errorText,
+ };
+
+ const actionsContextValue: NumericFieldActionsContextValue = {
+ setNumber: controller.setNumber,
+ handleSelectionChange: controller.handleSelectionChange,
+ handleKeyPress: controller.handleKeyPress,
+ };
+
+ return (
+
+ {children}
+
+ );
+}
+
+export default NumericField;
diff --git a/src/components/NumericField/context/NumericFieldContext.ts b/src/components/NumericField/context/NumericFieldContext.ts
new file mode 100644
index 000000000000..977d4e0191ab
--- /dev/null
+++ b/src/components/NumericField/context/NumericFieldContext.ts
@@ -0,0 +1,18 @@
+import createContextNamespace from '@hooks/createContextNamespace';
+
+import type {NumericFieldActionsContextValue, NumericFieldStateContextValue} from './types';
+
+const createNumericFieldContext = createContextNamespace('NumericField');
+
+const [NumericFieldStateContext, useNumericFieldStateContext] = createNumericFieldContext('State');
+const [NumericFieldActionsContext, useNumericFieldActionsContext] = createNumericFieldContext('Actions');
+
+function useNumericFieldState() {
+ return useNumericFieldStateContext('useNumericFieldState');
+}
+
+function useNumericFieldActions() {
+ return useNumericFieldActionsContext('useNumericFieldActions');
+}
+
+export {NumericFieldActionsContext, NumericFieldStateContext, useNumericFieldActions, useNumericFieldState};
diff --git a/src/components/NumericField/context/index.ts b/src/components/NumericField/context/index.ts
new file mode 100644
index 000000000000..f51bc3b0e573
--- /dev/null
+++ b/src/components/NumericField/context/index.ts
@@ -0,0 +1 @@
+export {NumericFieldActionsContext, NumericFieldStateContext, useNumericFieldActions, useNumericFieldState} from './NumericFieldContext';
diff --git a/src/components/NumericField/context/types.ts b/src/components/NumericField/context/types.ts
new file mode 100644
index 000000000000..d719b27b0089
--- /dev/null
+++ b/src/components/NumericField/context/types.ts
@@ -0,0 +1,30 @@
+import type {NumericEditingKeyPressEvent, NumericEditingSelection} from '@components/NumericEditingController/types';
+
+type NumericFieldStateContextValue = {
+ /** Canonical signed value owned by the root. */
+ value: string;
+
+ /** Canonical value rendered with locale digits. */
+ formattedNumber: string;
+
+ /** Selection clamped to the displayed text. */
+ selection: NumericEditingSelection;
+
+ allowNegative: boolean;
+
+ /** Error supplied by FormProvider and rendered by the text input. */
+ errorText?: string;
+};
+
+type NumericFieldActionsContextValue = {
+ /** Normalizes, validates, and commits displayed text. */
+ setNumber: (text: string) => void;
+
+ /** Applies a native selection change, dropping stale events from manual updates. */
+ handleSelectionChange: (selectionStart: number, selectionEnd: number) => void;
+
+ /** Tracks forward-delete key presses for caret positioning. */
+ handleKeyPress: (event: NumericEditingKeyPressEvent) => void;
+};
+
+export type {NumericFieldActionsContextValue, NumericFieldStateContextValue};
diff --git a/src/components/NumericField/index.tsx b/src/components/NumericField/index.tsx
new file mode 100644
index 000000000000..a91a40c22b38
--- /dev/null
+++ b/src/components/NumericField/index.tsx
@@ -0,0 +1,39 @@
+/**
+ * NumericField – a composable field for editing numeric values.
+ *
+ * The root component owns the canonical value, the selection, validation, and
+ * the input callbacks through a root-instantiated edit controller. Compose the
+ * input presentation as a child:
+ *
+ * @example
+ * ```tsx
+ * import NumericField from '@components/NumericField';
+ *
+ *
+ *
+ *
+ * ```
+ *
+ * The `useNumericFieldState` and `useNumericFieldActions` hooks are also exported
+ * for custom composed primitives.
+ */
+import NumericFieldComponent from './NumericField';
+import NumericTextInput from './primitives/NumericTextInput';
+
+const NumericField = Object.assign(NumericFieldComponent, {
+ /** Renders a numeric input using the standard text input component. */
+ TextInput: NumericTextInput,
+});
+
+export default NumericField;
+export {useNumericFieldActions, useNumericFieldState} from './context';
+export type {NumericFieldRef, NumericTextInputProps} from './types';
diff --git a/src/components/NumericField/primitives/NumericTextInput.tsx b/src/components/NumericField/primitives/NumericTextInput.tsx
new file mode 100644
index 000000000000..c02a3c837c37
--- /dev/null
+++ b/src/components/NumericField/primitives/NumericTextInput.tsx
@@ -0,0 +1,80 @@
+import type {NumericEditingKeyPressEvent} from '@components/NumericEditingController';
+import {useNumericFieldActions, useNumericFieldState} from '@components/NumericField/context';
+import type {NumericTextInputProps} from '@components/NumericField/types';
+import TextInput from '@components/TextInput';
+
+import useThemeStyles from '@hooks/useThemeStyles';
+
+import CONST from '@src/CONST';
+
+import type {TextInputSelectionChangeEvent} from 'react-native';
+
+/** Text input primitive connected to a NumericField root. */
+function NumericTextInput({
+ prefixCharacter = '',
+ ref,
+ onKeyPress,
+ onBlur,
+ accessibilityLabel,
+ autoFocus,
+ contentWidth,
+ disabled,
+ disableKeyboard,
+ keyboardType,
+ label,
+ onFocus,
+ prefixContainerStyle,
+ shouldApplyPaddingToContainer,
+ shouldUseDefaultLineHeightForPrefix,
+ onSubmitEditing,
+ submitBehavior,
+ testID,
+ touchableInputWrapperStyle,
+ style,
+}: NumericTextInputProps) {
+ const styles = useThemeStyles();
+ const {errorText, formattedNumber, selection} = useNumericFieldState();
+ const {handleKeyPress, handleSelectionChange, setNumber} = useNumericFieldActions();
+
+ const handleInputKeyPress = (event: NumericEditingKeyPressEvent) => {
+ handleKeyPress(event);
+ onKeyPress?.(event);
+ };
+
+ return (
+ handleSelectionChange(event.nativeEvent.selection.start, event.nativeEvent.selection.end)}
+ onSubmitEditing={onSubmitEditing}
+ prefixCharacter={prefixCharacter}
+ prefixContainerStyle={prefixContainerStyle}
+ prefixStyle={styles.colorMuted}
+ ref={ref}
+ selection={selection}
+ shouldApplyPaddingToContainer={shouldApplyPaddingToContainer}
+ shouldUseDefaultLineHeightForPrefix={shouldUseDefaultLineHeightForPrefix}
+ submitBehavior={submitBehavior}
+ testID={testID}
+ touchableInputWrapperStyle={touchableInputWrapperStyle}
+ value={formattedNumber}
+ />
+ );
+}
+
+export default NumericTextInput;
diff --git a/src/components/NumericField/types.ts b/src/components/NumericField/types.ts
new file mode 100644
index 000000000000..59f69b83f99b
--- /dev/null
+++ b/src/components/NumericField/types.ts
@@ -0,0 +1,39 @@
+import type {NumericEditingKeyPressEvent, NumericEditingRef} from '@components/NumericEditingController/types';
+import type {BaseTextInputProps, BaseTextInputRef} from '@components/TextInput/BaseTextInput/types';
+
+import type {ForwardedRef} from 'react';
+import type {StyleProp, TextStyle} from 'react-native';
+
+type NumericFieldRef = NumericEditingRef;
+
+type NumericTextInputProps = {
+ /** Style applied to the number input. */
+ style?: StyleProp;
+
+ /** Reference to the underlying text input. */
+ ref?: ForwardedRef;
+
+ /** Callback for keyboard events received by the numeric input. */
+ onKeyPress?: (event: NumericEditingKeyPressEvent) => void;
+} & Pick<
+ BaseTextInputProps,
+ | 'accessibilityLabel'
+ | 'autoFocus'
+ | 'contentWidth'
+ | 'disabled'
+ | 'disableKeyboard'
+ | 'keyboardType'
+ | 'label'
+ | 'onBlur'
+ | 'onFocus'
+ | 'onSubmitEditing'
+ | 'prefixCharacter'
+ | 'prefixContainerStyle'
+ | 'shouldApplyPaddingToContainer'
+ | 'shouldUseDefaultLineHeightForPrefix'
+ | 'submitBehavior'
+ | 'testID'
+ | 'touchableInputWrapperStyle'
+>;
+
+export type {NumericFieldRef, NumericTextInputProps};
diff --git a/src/components/OnyxListItemProvider.tsx b/src/components/OnyxListItemProvider.tsx
index 54a076658207..309d8a412537 100644
--- a/src/components/OnyxListItemProvider.tsx
+++ b/src/components/OnyxListItemProvider.tsx
@@ -15,6 +15,7 @@ const [PersonalDetailsProvider, , usePersonalDetails] = createOnyxContext(ONYXKE
const [BlockedFromConciergeProvider, , useBlockedFromConcierge] = createOnyxContext(ONYXKEYS.NVP_BLOCKED_FROM_CONCIERGE);
const [BetasProvider, BetasContext] = createOnyxContext(ONYXKEYS.BETAS);
const [BetaConfigurationProvider, BetaConfigurationContext] = createOnyxContext(ONYXKEYS.BETA_CONFIGURATION);
+const [BetaOverridesProvider, BetaOverridesContext] = createOnyxContext(ONYXKEYS.BETA_OVERRIDES);
const [SessionProvider, , useSession] = createOnyxContext(ONYXKEYS.SESSION);
const [PolicyCategoriesProvider, , usePolicyCategories] = createOnyxContext(ONYXKEYS.COLLECTION.POLICY_CATEGORIES);
const [PolicyTagsProvider, , usePolicyTags] = createOnyxContext(ONYXKEYS.COLLECTION.POLICY_TAGS);
@@ -35,6 +36,7 @@ function OnyxListItemProvider(props: OnyxListItemProviderProps) {
BlockedFromConciergeProvider,
BetasProvider,
BetaConfigurationProvider,
+ BetaOverridesProvider,
SessionProvider,
PolicyCategoriesProvider,
PolicyTagsProvider,
@@ -56,6 +58,7 @@ export {
usePersonalDetails,
BetasContext,
BetaConfigurationContext,
+ BetaOverridesContext,
useBlockedFromConcierge,
useSession,
usePolicyCategories,
diff --git a/src/components/PopoverMenu/v2/content/ScrollableContent.tsx b/src/components/PopoverMenu/v2/content/ScrollableContent.tsx
index 95cc55b11527..09123411da89 100644
--- a/src/components/PopoverMenu/v2/content/ScrollableContent.tsx
+++ b/src/components/PopoverMenu/v2/content/ScrollableContent.tsx
@@ -36,7 +36,7 @@ function ScrollableContent({contentContainerStyle, children, ...rest}: Scrollabl
const childCount = React.Children.count(children);
if (childCount > VIRTUALIZATION_RECOMMENDED_THRESHOLD) {
Log.warn(
- ` received ${childCount} children — renders all rows synchronously and will jank on lower-end devices for unbounded counts. Consider a virtualized list (FlashList) wrapper.`,
+ ` received ${childCount} children — renders all rows synchronously and will jank on lower-end devices for unbounded counts. Consider a virtualized list (LegendList) wrapper.`,
);
}
}
diff --git a/src/components/ReportActionItem/LocalPDFReceiptPreview/index.tsx b/src/components/ReportActionItem/LocalPDFReceiptPreview/index.tsx
index 27171c3e183d..84f9189259f4 100644
--- a/src/components/ReportActionItem/LocalPDFReceiptPreview/index.tsx
+++ b/src/components/ReportActionItem/LocalPDFReceiptPreview/index.tsx
@@ -5,9 +5,10 @@ import PDFThumbnailError from '@components/PDFThumbnail/PDFThumbnailError';
import useThemeStyles from '@hooks/useThemeStyles';
+import {useReportActionItemState} from '@pages/inbox/report/ReportActionIndexContext';
+
import CONST from '@src/CONST';
-import React, {useState} from 'react';
import {View} from 'react-native';
import type LocalPDFReceiptPreviewProps from './types';
@@ -18,9 +19,9 @@ const DOCUMENT_OPTIONS = {cMapUrl: '/cmaps/', cMapPacked: true};
function LocalPDFReceiptPreview({sourceURL, shouldUseFullHeight, onLoadFailure, onLoadSuccess}: LocalPDFReceiptPreviewProps) {
const styles = useThemeStyles();
- const [failedToLoad, setFailedToLoad] = useState(false);
- const [containerSize, setContainerSize] = useState<{width: number; height: number} | undefined>(undefined);
- const [pageAspectRatio, setPageAspectRatio] = useState(undefined);
+ const [failedToLoad, setFailedToLoad] = useReportActionItemState(false);
+ const [containerSize, setContainerSize] = useReportActionItemState<{width: number; height: number} | undefined>(undefined);
+ const [pageAspectRatio, setPageAspectRatio] = useReportActionItemState(undefined);
const handleDocumentLoadSuccess = (pdf: PDFDocumentProxy) => {
pdf.getPage(1)
diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewProvider.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewProvider.tsx
index 280a4d2433e1..7481504b3ae7 100644
--- a/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewProvider.tsx
+++ b/src/components/ReportActionItem/MoneyRequestReportPreview/MoneyRequestReportPreviewProvider.tsx
@@ -25,11 +25,11 @@ import type {PersonalDetails, Policy, Report, ReportAction, Transaction, Transac
import type {PaymentMethodType} from '@src/types/onyx/OriginalMessage';
import type ChildrenProps from '@src/types/utils/ChildrenProps';
-import type {ListRenderItem} from '@shopify/flash-list';
+import type {LegendListProps} from '@legendapp/list/react-native';
import type {OnyxEntry} from 'react-native-onyx';
import {useFocusEffect} from '@react-navigation/native';
-import React, {useCallback, useDeferredValue, useState} from 'react';
+import React, {useDeferredValue, useState} from 'react';
import type {MoneyRequestReportPreviewStyleType} from './types';
@@ -66,7 +66,7 @@ type MoneyRequestReportPreviewProviderProps = ChildrenProps & {
lastTransactionViolations: TransactionViolations;
onPaymentOptionsShow?: () => void;
onPaymentOptionsHide?: () => void;
- renderTransactionItem: ListRenderItem;
+ renderTransactionItem: NonNullable['renderItem']>;
onOrderedTransactionsChange?: (orderedTransactions: Transaction[]) => void;
onCancelPendingPress?: () => void;
currentWidth: number;
@@ -118,18 +118,16 @@ function MoneyRequestReportPreviewProvider({
methodID: number | undefined;
} | null>(null);
- useFocusEffect(
- useCallback(() => {
- if (!isTransitionPending) {
- return;
- }
- const handle = TransitionTracker.runAfterTransitions({
- callback: () => setIsTransitionPending(false),
- waitForUpcomingTransition: true,
- });
- return () => handle.cancel();
- }, [isTransitionPending]),
- );
+ useFocusEffect(() => {
+ if (!isTransitionPending) {
+ return;
+ }
+ const handle = TransitionTracker.runAfterTransitions({
+ callback: () => setIsTransitionPending(false),
+ waitForUpcomingTransition: true,
+ });
+ return () => handle.cancel();
+ });
const shouldShowLoading = chatReportLoadingState != null && chatReportLoadingState.hasOnceLoadedReportActions !== true && transactions.length === 0 && !isOptimisticChatReport;
const [transactionViolations] = useReportTransactionViolations(transactions);
@@ -207,7 +205,7 @@ function MoneyRequestReportPreviewProvider({
onOrderedTransactionsChange,
});
- const openReportFromPreview = useCallback(() => {
+ const openReportFromPreview = () => {
if (!iouReportID) {
return;
}
@@ -232,13 +230,13 @@ function MoneyRequestReportPreviewProvider({
} else {
Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo}));
}
- }, [iouReportID, isSmallScreenWidth, onCancelPendingPress]);
+ };
// Only the pay flow opens this menu; approve surfaces its partial/full choice in the approve dropdown instead.
- const onHoldMenuOpen = useCallback((paymentType?: PaymentMethodType, canPay?: boolean, methodID?: number) => {
+ const onHoldMenuOpen = (paymentType?: PaymentMethodType, canPay?: boolean, methodID?: number) => {
setHoldMenu({paymentType, canPay: !!canPay, methodID});
- }, []);
- const onHoldMenuClose = useCallback(() => setHoldMenu(null), []);
+ };
+ const onHoldMenuClose = () => setHoldMenu(null);
const shouldShowCarouselArrows = !shouldUseNarrowLayout && !shouldShowAccessPlaceHolder && transactions.length > 2 && reportPreviewStyles.expenseCountVisible;
const buttonMaxWidth =
diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/TransactionReportCarousel.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/TransactionReportCarousel.tsx
index 630bf5135711..bee2b5ca99bc 100644
--- a/src/components/ReportActionItem/MoneyRequestReportPreview/TransactionReportCarousel.tsx
+++ b/src/components/ReportActionItem/MoneyRequestReportPreview/TransactionReportCarousel.tsx
@@ -5,7 +5,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';
-import {FlashList} from '@shopify/flash-list';
+import {LegendList} from '@legendapp/list/react-native';
import React from 'react';
import {View} from 'react-native';
@@ -49,7 +49,8 @@ function TransactionReportCarousel() {
return (
- `${item.transactionID}_${reportPreviewStyles.transactionPreviewCarouselStyle.width}`}
diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx
index d5e3d80e3507..7b9d07c220dc 100644
--- a/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx
+++ b/src/components/ReportActionItem/MoneyRequestReportPreview/index.tsx
@@ -18,13 +18,16 @@ import {createTransactionThreadReport, openReport, setOptimisticTransactionThrea
import {clearActiveTransactionIDs, getActiveTransactionIDs, setActiveTransactionIDs} from '@libs/actions/TransactionThreadNavigation';
import getNonEmptyStringOnyxID from '@libs/getNonEmptyStringOnyxID';
import {
+ getAllReportActions,
getIOUActionForReportID,
+ getIOUActionForTransactionID,
getOriginalMessage,
+ isDeletedAction,
isMoneyRequestAction,
isSplitBillAction as isSplitBillActionReportActionsUtils,
isTrackExpenseAction as isTrackExpenseActionReportActionsUtils,
} from '@libs/ReportActionsUtils';
-import {areAllRequestsBeingSmartScanned as areAllRequestsBeingSmartScannedReportUtils, getTransactionsWithReceipts, isIOUReport} from '@libs/ReportUtils';
+import {areAllRequestsBeingSmartScanned as areAllRequestsBeingSmartScannedReportUtils, getReportOrDraftReport, getTransactionsWithReceipts, isIOUReport} from '@libs/ReportUtils';
import {startSpan} from '@libs/telemetry/activeSpans';
import {hasNonReimbursableTransactions as hasNonReimbursableTransactionsTransactionUtils, isTransactionPendingDelete} from '@libs/TransactionUtils';
@@ -36,14 +39,14 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import ROUTES from '@src/ROUTES';
import {hasOnceLoadedReportActionsSelector, isLoadingInitialReportActionsSelector, pendingNewTransactionIDsSelector} from '@src/selectors/ReportMetaData';
-import type {ReportActions, Transaction} from '@src/types/onyx';
+import type {ReportAction, ReportActions, Transaction} from '@src/types/onyx';
-import type {ListRenderItem} from '@shopify/flash-list';
+import type {LegendListProps} from '@legendapp/list/react-native';
import type {LayoutChangeEvent} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
import {useIsFocused} from '@react-navigation/core';
-import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import React, {useEffect, useRef, useState} from 'react';
import type {MoneyRequestReportPreviewProps} from './types';
@@ -55,6 +58,10 @@ const hasReportActionsSelector = (reportActions: OnyxEntry) => Ob
// The stagger between the report and the expense that design asked for: https://github.com/Expensify/App/pull/92546#issuecomment-4687440972
const PRESSED_EXPENSE_CASCADE_DELAY = 180;
+function isLiveIOUAction(reportAction: OnyxEntry): reportAction is ReportAction {
+ return !!reportAction && !isDeletedAction(reportAction) && reportAction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE;
+}
+
function MoneyRequestReportPreview({
iouReportID,
iouReport,
@@ -91,21 +98,21 @@ function MoneyRequestReportPreview({
const allReportTransactions = Object.values(reportTransactionsCollection ?? {}).filter((transaction): transaction is Transaction => !!transaction);
const transactions = allReportTransactions.filter((transaction) => isOffline || transaction.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE);
const orderedTransactionsRef = useRef([]);
- const handleOrderedTransactionsChange = useCallback((orderedTransactions: Transaction[]) => {
+ const handleOrderedTransactionsChange = (orderedTransactions: Transaction[]) => {
orderedTransactionsRef.current = orderedTransactions;
- }, []);
+ };
const [hasIOUReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(iouReportID)}`, {
selector: hasReportActionsSelector,
});
const pendingExpenseTransactionRef = useRef<{transaction: Transaction; originRoute: string} | null>(null);
// Subscribing to the whole action list re-rendered every card on each write, and a split writes a burst of
// them. Narrow it to the one action a deferred press is waiting for, so nothing pending means nothing changes.
- const pendingPressActionCountSelector = useCallback((reportActions: OnyxEntry) => {
+ const pendingPressActionCountSelector = (reportActions: OnyxEntry) => {
if (!pendingExpenseTransactionRef.current) {
return undefined;
}
return Object.keys(reportActions ?? {}).length;
- }, []);
+ };
const [pendingPressActionCount] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT_ACTIONS}${getNonEmptyStringOnyxID(iouReportID)}`, {
selector: pendingPressActionCountSelector,
});
@@ -125,48 +132,34 @@ function MoneyRequestReportPreview({
const widthsRef = useRef<{currentWidth: number | null; currentWrapperWidth: number | null}>({currentWidth: null, currentWrapperWidth: null});
const [widths, setWidths] = useState({currentWidth: 0, currentWrapperWidth: 0});
- const updateWidths = useCallback(() => {
+ const updateWidths = () => {
const {currentWidth, currentWrapperWidth} = widthsRef.current;
if (currentWidth && currentWrapperWidth) {
setWidths({currentWidth, currentWrapperWidth});
}
- }, []);
-
- const onCarouselLayout = useCallback(
- (e: LayoutChangeEvent) => {
- const newWidth = e.nativeEvent.layout.width;
- if (widthsRef.current.currentWidth !== newWidth) {
- widthsRef.current.currentWidth = newWidth;
- updateWidths();
- }
- },
- [updateWidths],
- );
- const onWrapperLayout = useCallback(
- (e: LayoutChangeEvent) => {
- const newWrapperWidth = e.nativeEvent.layout.width;
- if (widthsRef.current.currentWrapperWidth !== newWrapperWidth) {
- widthsRef.current.currentWrapperWidth = newWrapperWidth;
- updateWidths();
- }
- },
- [updateWidths],
- );
+ };
- const reportPreviewStyles = useMemo(
- () => StyleUtils.getMoneyRequestReportPreviewStyle(shouldUseNarrowLayoutIgnoringWideRHP, transactions.length, widths.currentWidth, widths.currentWrapperWidth),
- [StyleUtils, widths, shouldUseNarrowLayoutIgnoringWideRHP, transactions.length],
- );
- const shouldShowPayerAndReceiver = useMemo(() => {
- if (!isIOUReport(iouReport) && action.childType !== CONST.REPORT.TYPE.IOU) {
- return false;
+ const onCarouselLayout = (e: LayoutChangeEvent) => {
+ const newWidth = e.nativeEvent.layout.width;
+ if (widthsRef.current.currentWidth !== newWidth) {
+ widthsRef.current.currentWidth = newWidth;
+ updateWidths();
}
+ };
+ const onWrapperLayout = (e: LayoutChangeEvent) => {
+ const newWrapperWidth = e.nativeEvent.layout.width;
+ if (widthsRef.current.currentWrapperWidth !== newWrapperWidth) {
+ widthsRef.current.currentWrapperWidth = newWrapperWidth;
+ updateWidths();
+ }
+ };
- return transactions.some((transaction) => (Number(transaction?.modifiedAmount) || transaction?.amount) < 0);
- }, [transactions, action.childType, iouReport]);
+ const reportPreviewStyles = StyleUtils.getMoneyRequestReportPreviewStyle(shouldUseNarrowLayoutIgnoringWideRHP, transactions.length, widths.currentWidth, widths.currentWrapperWidth);
+ const shouldShowPayerAndReceiver =
+ (isIOUReport(iouReport) || action.childType === CONST.REPORT.TYPE.IOU) && transactions.some((transaction) => (Number(transaction?.modifiedAmount) || transaction?.amount) < 0);
- const cancelPendingPress = useCallback(() => {
+ const cancelPendingPress = () => {
pendingExpenseTransactionRef.current = null;
if (!cascadeTimerRef.current) {
return;
@@ -174,9 +167,9 @@ function MoneyRequestReportPreview({
clearTimeout(cascadeTimerRef.current.timer);
cascadeTimerRef.current.release();
cascadeTimerRef.current = null;
- }, []);
+ };
- const openReportFromPreview = useCallback(() => {
+ const openReportFromPreview = () => {
if (!iouReportID || contextMenuRef.current?.isContextMenuOpening) {
return;
}
@@ -202,7 +195,7 @@ function MoneyRequestReportPreview({
} else {
Navigation.navigate(ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo}));
}
- }, [cancelPendingPress, iouReportID, isSmallScreenWidth]);
+ };
const [hasOnceLoadedReportActions] = useOnyx(`${ONYXKEYS.COLLECTION.RAM_ONLY_REPORT_LOADING_STATE}${chatReportID}`, {
selector: hasOnceLoadedReportActionsSelector,
});
@@ -229,180 +222,154 @@ function MoneyRequestReportPreview({
const transactionPreviewContainerStyles = [styles.h100, reportPreviewStyles.transactionPreviewCarouselStyle];
- const resolveChildReportID = useCallback(
- (transaction: Transaction) => {
- const transactionIOUAction = getIOUActionForReportID(transaction.reportID, transaction.transactionID);
- let childReportID = transactionIOUAction?.childReportID ?? transaction.transactionThreadReportID;
- if (childReportID) {
- setOptimisticTransactionThread(childReportID, iouReport?.reportID ?? transaction.reportID, transactionIOUAction?.reportActionID, iouReport?.policyID ?? policyID);
- } else if (transactionIOUAction?.reportActionID) {
- const transactionID = isMoneyRequestAction(transactionIOUAction) ? getOriginalMessage(transactionIOUAction)?.IOUTransactionID : undefined;
- if (transactionID) {
- childReportID = createTransactionThreadReport({
- introSelected,
- conciergeChat,
- currentUserLogin: currentUserEmail ?? '',
- currentUserAccountID,
- betas,
- iouReport,
- iouReportAction: transactionIOUAction,
- personalDetails: personalDetailsList,
- })?.reportID;
- }
+ const resolveChildReportID = (transaction: Transaction) => {
+ let transactionIOUAction = getIOUActionForReportID(transaction.reportID, transaction.transactionID);
+ if (transactionIOUAction && !isLiveIOUAction(transactionIOUAction)) {
+ const liveIOUAction = getIOUActionForTransactionID(Object.values(getAllReportActions(transaction.reportID) ?? {}).filter(isLiveIOUAction), transaction.transactionID);
+ if (!liveIOUAction) {
+ return undefined;
}
- return childReportID;
- },
- [betas, conciergeChat, currentUserAccountID, currentUserEmail, introSelected, iouReport, personalDetailsList, policyID],
- );
+ transactionIOUAction = liveIOUAction;
+ }
+ let childReportID = transactionIOUAction?.childReportID ?? transaction.transactionThreadReportID;
+ if (childReportID) {
+ const existingThread = getReportOrDraftReport(childReportID);
+ if (existingThread && !existingThread.reportID) {
+ return undefined;
+ }
+ setOptimisticTransactionThread(childReportID, iouReport?.reportID ?? transaction.reportID, transactionIOUAction?.reportActionID, iouReport?.policyID ?? policyID);
+ } else if (transactionIOUAction?.reportActionID) {
+ const transactionID = isMoneyRequestAction(transactionIOUAction) ? getOriginalMessage(transactionIOUAction)?.IOUTransactionID : undefined;
+ if (transactionID) {
+ childReportID = createTransactionThreadReport({
+ introSelected,
+ conciergeChat,
+ currentUserLogin: currentUserEmail ?? '',
+ currentUserAccountID,
+ betas,
+ iouReport,
+ iouReportAction: transactionIOUAction,
+ personalDetails: personalDetailsList,
+ })?.reportID;
+ }
+ }
+ return childReportID;
+ };
// `routeAtPress` is captured when the user pressed, not read live: a second press inside the cascade window
// runs after the first press already navigated, so the live route is the report we opened and `backTo` would
// point at itself.
- const navigateToExpense = useCallback(
- (childReportID: string, routeAtPress: string) => {
- startSpan(`${CONST.TELEMETRY.SPAN_OPEN_REPORT}_${childReportID}`, {
- name: 'MoneyRequestReportPreview.Transaction',
- op: CONST.TELEMETRY.SPAN_OPEN_REPORT,
- });
-
- const openableTransactionIDs = (orderedTransactionsRef.current.length > 0 ? orderedTransactionsRef.current : transactions)
- .filter((pressedTransaction) => !isTransactionPendingDelete(pressedTransaction))
- .map((pressedTransaction) => pressedTransaction.transactionID);
-
- if (isSmallScreenWidth && iouReportID) {
- const {wasPressedFromReport, backTo} = resolvePressOrigin(routeAtPress, `r/${iouReportID}`);
- const reportRoute = ROUTES.REPORT_WITH_ID.getRoute(iouReportID, undefined, undefined, backTo);
- if (!wasPressedFromReport) {
- Navigation.navigate(reportRoute);
- }
- const seeded = setActiveTransactionIDs(openableTransactionIDs);
- const release = () => {
- seeded.then(() => {
- if (getActiveTransactionIDs().ids !== openableTransactionIDs) {
- return;
- }
- clearActiveTransactionIDs();
- });
- };
- const timer = setTimeout(() => {
- cascadeTimerRef.current = null;
- if (!Navigation.isActiveRoute(reportRoute)) {
- release();
- return;
- }
- Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: reportRoute}));
- }, PRESSED_EXPENSE_CASCADE_DELAY);
- cascadeTimerRef.current = {timer, release};
- return;
- }
+ const navigateToExpense = (childReportID: string, routeAtPress: string) => {
+ startSpan(`${CONST.TELEMETRY.SPAN_OPEN_REPORT}_${childReportID}`, {
+ name: 'MoneyRequestReportPreview.Transaction',
+ op: CONST.TELEMETRY.SPAN_OPEN_REPORT,
+ });
- if (isSmallScreenWidth) {
- setActiveTransactionIDs(openableTransactionIDs);
- Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: routeAtPress}));
- return;
+ const openableTransactionIDs = (orderedTransactionsRef.current.length > 0 ? orderedTransactionsRef.current : transactions)
+ .filter((pressedTransaction) => !isTransactionPendingDelete(pressedTransaction))
+ .map((pressedTransaction) => pressedTransaction.transactionID);
+
+ if (isSmallScreenWidth && iouReportID) {
+ const {wasPressedFromReport, backTo} = resolvePressOrigin(routeAtPress, `r/${iouReportID}`);
+ const reportRoute = ROUTES.REPORT_WITH_ID.getRoute(iouReportID, undefined, undefined, backTo);
+ if (!wasPressedFromReport) {
+ Navigation.navigate(reportRoute);
}
+ setActiveTransactionIDs(openableTransactionIDs);
+ Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: reportRoute}));
+ return;
+ }
- if (iouReportID) {
- const {wasPressedFromReport, backTo} = resolvePressOrigin(routeAtPress, `e/${iouReportID}`);
- const reportRoute = ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo});
- markReportRHPWidth(iouReportID, 'super-wide');
- if (!wasPressedFromReport) {
- Navigation.navigate(reportRoute);
- }
- const seeded = setActiveTransactionIDs(openableTransactionIDs);
- markReportRHPWidth(childReportID, 'wide');
- const release = () => {
- unmarkReportRHPWidth(childReportID);
- // Only clears if the report still carries our hint, so it can't undo one set by the report itself.
- unmarkReportRHPWidth(iouReportID, 'super-wide');
- seeded.then(() => {
- if (getActiveTransactionIDs().ids !== openableTransactionIDs) {
- return;
- }
- clearActiveTransactionIDs();
- });
- };
- const timer = setTimeout(() => {
- cascadeTimerRef.current = null;
- if (!Navigation.isActiveRoute(reportRoute)) {
- release();
+ if (isSmallScreenWidth) {
+ setActiveTransactionIDs(openableTransactionIDs);
+ Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: routeAtPress}));
+ return;
+ }
+
+ if (iouReportID) {
+ const {wasPressedFromReport, backTo} = resolvePressOrigin(routeAtPress, `e/${iouReportID}`);
+ const reportRoute = ROUTES.EXPENSE_REPORT_RHP.getRoute({reportID: iouReportID, backTo});
+ markReportRHPWidth(iouReportID, 'super-wide');
+ if (!wasPressedFromReport) {
+ Navigation.navigate(reportRoute);
+ }
+ const seeded = setActiveTransactionIDs(openableTransactionIDs);
+ markReportRHPWidth(childReportID, 'wide');
+ const release = () => {
+ unmarkReportRHPWidth(childReportID);
+ // Only clears if the report still carries our hint, so it can't undo one set by the report itself.
+ unmarkReportRHPWidth(iouReportID, 'super-wide');
+ seeded.then(() => {
+ if (getActiveTransactionIDs().ids !== openableTransactionIDs) {
return;
}
- Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: reportRoute}));
- }, PRESSED_EXPENSE_CASCADE_DELAY);
- cascadeTimerRef.current = {timer, release};
- return;
- }
+ clearActiveTransactionIDs();
+ });
+ };
+ const timer = setTimeout(() => {
+ cascadeTimerRef.current = null;
+ if (!Navigation.isActiveRoute(reportRoute)) {
+ release();
+ return;
+ }
+ Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: reportRoute}));
+ }, PRESSED_EXPENSE_CASCADE_DELAY);
+ cascadeTimerRef.current = {timer, release};
+ return;
+ }
- setActiveTransactionIDs(openableTransactionIDs).then(() => {
- markReportRHPWidth(childReportID, 'wide');
- Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: routeAtPress}));
- });
- },
- [isSmallScreenWidth, iouReportID, markReportRHPWidth, unmarkReportRHPWidth, transactions],
- );
+ setActiveTransactionIDs(openableTransactionIDs).then(() => {
+ markReportRHPWidth(childReportID, 'wide');
+ Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID: childReportID, backTo: routeAtPress}));
+ });
+ };
- const openTransactionFromPreview = useCallback(
- (transaction: Transaction) => {
- if (contextMenuRef.current?.isContextMenuOpening) {
- return;
- }
+ const openTransactionFromPreview = (transaction: Transaction) => {
+ if (contextMenuRef.current?.isContextMenuOpening) {
+ return;
+ }
- const routeAtPress = Navigation.getActiveRoute();
+ const routeAtPress = Navigation.getActiveRoute();
- pendingExpenseTransactionRef.current = null;
- if (cascadeTimerRef.current) {
- clearTimeout(cascadeTimerRef.current.timer);
- cascadeTimerRef.current.release();
- cascadeTimerRef.current = null;
- }
+ pendingExpenseTransactionRef.current = null;
+ if (cascadeTimerRef.current) {
+ clearTimeout(cascadeTimerRef.current.timer);
+ cascadeTimerRef.current.release();
+ cascadeTimerRef.current = null;
+ }
- if (transactions.length <= 1) {
- openReportFromPreview();
- return;
- }
+ if (transactions.length <= 1) {
+ openReportFromPreview();
+ return;
+ }
- if (transaction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) {
- openReportFromPreview();
- return;
- }
+ if (transaction.pendingAction === CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE) {
+ openReportFromPreview();
+ return;
+ }
- const isIOUActionLoaded = !!getIOUActionForReportID(transaction.reportID, transaction.transactionID);
- const childReportID = resolveChildReportID(transaction);
- if (childReportID) {
- if (!isIOUActionLoaded && iouReportID) {
- if (isOffline) {
- openReportFromPreview();
- return;
- }
- openReport({reportID: iouReportID, introSelected, conciergeChat, betas, currentUserAccountID, hasReportActions: !!hasIOUReportActions});
+ const isIOUActionLoaded = !!getIOUActionForReportID(transaction.reportID, transaction.transactionID);
+ const childReportID = resolveChildReportID(transaction);
+ if (childReportID) {
+ if (!isIOUActionLoaded && iouReportID) {
+ if (isOffline) {
+ openReportFromPreview();
+ return;
}
- navigateToExpense(childReportID, routeAtPress);
- return;
- }
-
- if (!isIOUActionLoaded && iouReportID && !isOffline) {
- pendingExpenseTransactionRef.current = {transaction, originRoute: routeAtPress};
openReport({reportID: iouReportID, introSelected, conciergeChat, betas, currentUserAccountID, hasReportActions: !!hasIOUReportActions});
- return;
}
+ navigateToExpense(childReportID, routeAtPress);
+ return;
+ }
- openReportFromPreview();
- },
- [
- betas,
- conciergeChat,
- currentUserAccountID,
- hasIOUReportActions,
- introSelected,
- iouReportID,
- isOffline,
- navigateToExpense,
- openReportFromPreview,
- resolveChildReportID,
- transactions.length,
- ],
- );
+ if (!isIOUActionLoaded && iouReportID && !isOffline) {
+ pendingExpenseTransactionRef.current = {transaction, originRoute: routeAtPress};
+ openReport({reportID: iouReportID, introSelected, conciergeChat, betas, currentUserAccountID, hasReportActions: !!hasIOUReportActions});
+ return;
+ }
+
+ openReportFromPreview();
+ };
useEffect(() => {
const pendingPress = pendingExpenseTransactionRef.current;
@@ -436,7 +403,7 @@ function MoneyRequestReportPreview({
[],
);
- const renderItem: ListRenderItem = ({item}) => {
+ const renderItem: NonNullable['renderItem']> = ({item}) => {
const transactionIOUAction = getIOUActionForReportID(item.reportID, item.transactionID);
return (
void;
- renderTransactionItem: ListRenderItem;
+ renderTransactionItem: NonNullable['renderItem']>;
/** Called with the transactions in the order the carousel renders them */
onOrderedTransactionsChange?: (orderedTransactions: Transaction[]) => void;
diff --git a/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewCarousel.tsx b/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewCarousel.tsx
index ede408e2a1b1..e408fc247485 100644
--- a/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewCarousel.tsx
+++ b/src/components/ReportActionItem/MoneyRequestReportPreview/useReportPreviewCarousel.tsx
@@ -15,11 +15,11 @@ import ONYXKEYS from '@src/ONYXKEYS';
import {personalDetailsLoginSelector} from '@src/selectors/PersonalDetails';
import type {Policy, Report, Transaction} from '@src/types/onyx';
-import type {FlashListRef, ListRenderItem, ListRenderItemInfo} from '@shopify/flash-list';
+import type {LegendListProps, LegendListRef} from '@legendapp/list/react-native';
import type {ViewToken} from 'react-native';
import type {OnyxEntry} from 'react-native-onyx';
-import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react';
+import React, {useEffect, useRef, useState} from 'react';
import {View} from 'react-native';
import type {MoneyRequestReportPreviewStyleType} from './types';
@@ -60,7 +60,7 @@ type UseReportPreviewCarouselParams = {
newTransactionIDs?: Set;
/** Renders a single transaction preview item */
- renderTransactionItem: ListRenderItem;
+ renderTransactionItem: NonNullable['renderItem']>;
};
/**
@@ -85,31 +85,27 @@ function useReportPreviewCarousel({
const [ownerLogin] = useOnyx(ONYXKEYS.PERSONAL_DETAILS_LIST, {selector: personalDetailsLoginSelector(iouReport?.ownerAccountID)});
const isFocusedRef = useIsFocusedRef();
- const sortedTransactions = useMemo(() => {
- if (shouldShowAccessPlaceHolder) {
- return [];
- }
- const sorted = [...transactions].sort((a, b) => {
- const rbrComparison = compareByRBR(
- a,
- b,
- transactionViolations,
- currentUserDetails?.login ?? '',
- currentUserDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID,
- iouReport,
- ownerLogin,
- policy,
- );
- if (rbrComparison !== 0) {
- return rbrComparison;
- }
- // Tiebreak by date (ascending — oldest first) so position is stable across RBR state changes
- return localeCompare(getCreated(a), getCreated(b));
- });
- return sorted;
- }, [shouldShowAccessPlaceHolder, transactions, transactionViolations, currentUserDetails?.login, currentUserDetails?.accountID, iouReport, ownerLogin, policy, localeCompare]);
-
- const carouselTransactions = useMemo(() => sortedTransactions.slice(0, MAX_PREVIEWS_NUMBER + 1), [sortedTransactions]);
+ const sortedTransactions = shouldShowAccessPlaceHolder
+ ? []
+ : [...transactions].sort((a, b) => {
+ const rbrComparison = compareByRBR(
+ a,
+ b,
+ transactionViolations,
+ currentUserDetails?.login ?? '',
+ currentUserDetails?.accountID ?? CONST.DEFAULT_NUMBER_ID,
+ iouReport,
+ ownerLogin,
+ policy,
+ );
+ if (rbrComparison !== 0) {
+ return rbrComparison;
+ }
+ // Tiebreak by date (ascending — oldest first) so position is stable across RBR state changes
+ return localeCompare(getCreated(a), getCreated(b));
+ });
+
+ const carouselTransactions = sortedTransactions.slice(0, MAX_PREVIEWS_NUMBER + 1);
const prevCarouselTransactionLength = useRef(0);
useEffect(() => {
@@ -126,13 +122,13 @@ function useReportPreviewCarousel({
// value ensures that disabled state is applied instantly and not overridden by onViewableItemsChanged when scrolling
// undefined makes arrow buttons react on currentIndex changes when scrolling manually
const [optimisticIndex, setOptimisticIndex] = useState(undefined);
- const carouselRef = useRef | null>(null);
+ const carouselRef = useRef(null);
// Expose a callback ref instead of the ref object so the ref does not flow through the hook's return value
// (React Compiler forbids reading/passing refs during render).
- const setCarouselRef = useCallback((node: FlashListRef | null) => {
+ const setCarouselRef = (node: LegendListRef | null) => {
carouselRef.current = node;
- }, []);
+ };
const prevTransactionCountForScroll = useRef(carouselTransactions.length);
const [carouselKey, setCarouselKey] = useState(0);
@@ -148,15 +144,11 @@ function useReportPreviewCarousel({
prevTransactionCountForScroll.current = carouselTransactions.length;
}, [carouselTransactions.length]);
- const visibleItemsOnEndCount = useMemo(() => {
- const lastItemWidth = transactions.length > MAX_PREVIEWS_NUMBER ? footerWidth : reportPreviewStyles.transactionPreviewCarouselStyle.width;
- const lastItemWithGap = lastItemWidth + styles.gap2.gap;
- const itemWithGap = reportPreviewStyles.transactionPreviewCarouselStyle.width + styles.gap2.gap;
- return Math.floor((currentWidth - 2 * styles.pl2.paddingLeft - lastItemWithGap) / itemWithGap) + 1;
- }, [transactions.length, footerWidth, reportPreviewStyles.transactionPreviewCarouselStyle.width, styles.gap2.gap, styles.pl2.paddingLeft, currentWidth]);
- const viewabilityConfig = useMemo(() => {
- return {itemVisiblePercentThreshold: 100};
- }, []);
+ const lastItemWidth = transactions.length > MAX_PREVIEWS_NUMBER ? footerWidth : reportPreviewStyles.transactionPreviewCarouselStyle.width;
+ const lastItemWithGap = lastItemWidth + styles.gap2.gap;
+ const itemWithGap = reportPreviewStyles.transactionPreviewCarouselStyle.width + styles.gap2.gap;
+ const visibleItemsOnEndCount = Math.floor((currentWidth - 2 * styles.pl2.paddingLeft - lastItemWithGap) / itemWithGap) + 1;
+ const viewabilityConfig = {itemVisiblePercentThreshold: 100};
const carouselTransactionsRef = useRef(carouselTransactions);
@@ -194,14 +186,14 @@ function useReportPreviewCarousel({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [newTransactionIDs]);
- const onViewableItemsChanged = useCallback(({viewableItems}: {viewableItems: ViewToken[]; changed: ViewToken[]}) => {
+ const onViewableItemsChanged = ({viewableItems}: {viewableItems: ViewToken[]; changed: ViewToken[]}) => {
const newIndex = viewableItems.at(0)?.index;
if (typeof newIndex === 'number') {
setCurrentIndex(newIndex);
}
const viewableItemsIndexes = viewableItems.map((item) => item.index).filter((item): item is number => item !== null);
setCurrentVisibleItems(viewableItemsIndexes);
- }, []);
+ };
const snapOffsets = carouselTransactions.map((_, index) => index * (reportPreviewStyles.transactionPreviewCarouselStyle.width + styles.transactionsCarouselGap.width));
@@ -217,7 +209,7 @@ function useReportPreviewCarousel({
}
if (index < 0) {
setOptimisticIndex(0);
- carouselRef.current?.scrollToTop({animated: true});
+ carouselRef.current?.scrollToOffset({offset: 0, animated: true});
return;
}
if (index === carouselTransactions.length - visibleItemsOnEndCount) {
@@ -232,7 +224,7 @@ function useReportPreviewCarousel({
});
};
- const renderItem = (itemInfo: ListRenderItemInfo) => {
+ const renderItem: NonNullable['renderItem']> = (itemInfo) => {
if (itemInfo.index > MAX_PREVIEWS_NUMBER - 1) {
return (
{
+ const adjustScroll = () => {
// Workaround for a known React Native bug on Android (https://github.com/facebook/react-native/issues/27504):
// When the FlatList is scrolled to the end and the last item is deleted, a blank space is left behind.
// To fix this, we detect when onEndReached is triggered due to an item deletion,
@@ -277,7 +269,7 @@ function useReportPreviewCarousel({
}
prevCarouselTransactionLength.current = carouselTransactions.length;
carouselRef.current?.scrollToEnd();
- }, [carouselTransactions.length]);
+ };
const renderSeparator = () => ;
diff --git a/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx b/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx
index 6e64cd2d4184..24c0014f920c 100644
--- a/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx
+++ b/src/components/ReportActionItem/ReceiptPDFOverlay/index.tsx
@@ -3,6 +3,8 @@ import useThemeStyles from '@hooks/useThemeStyles';
import addEncryptedAuthTokenToURL from '@libs/addEncryptedAuthTokenToURL';
+import {useReportActionItemState} from '@pages/inbox/report/ReportActionIndexContext';
+
import variables from '@styles/variables';
import {retrieveMaxCanvasArea, retrieveMaxCanvasHeight, retrieveMaxCanvasWidth} from '@userActions/CanvasSize';
@@ -10,7 +12,7 @@ import {retrieveMaxCanvasArea, retrieveMaxCanvasHeight, retrieveMaxCanvasWidth}
import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
-import React, {useEffect, useState} from 'react';
+import React, {useEffect} from 'react';
import {PDFPreviewer} from 'react-fast-pdf';
import {View} from 'react-native';
@@ -52,7 +54,7 @@ function ReceiptPDFOverlay({sourceURL, isAuthTokenRequired = true, onLoadFailure
// Track which URL failed so hasFailed resets automatically when fileURL changes (e.g. after auth token refresh),
// mirroring the pattern in ThumbnailImage. No useEffect needed — the comparison runs synchronously during render.
- const [failedURL, setFailedURL] = useState(null);
+ const [failedURL, setFailedURL] = useReportActionItemState(null);
const hasFailed = failedURL !== null && failedURL === fileURL;
// If the PDF can't be rendered, fall back to the thumbnail underneath by rendering nothing.
diff --git a/src/components/ReportActionItem/ReportActionItemImages.tsx b/src/components/ReportActionItem/ReportActionItemImages.tsx
index 9a5615d3efba..f566ef523cce 100644
--- a/src/components/ReportActionItem/ReportActionItemImages.tsx
+++ b/src/components/ReportActionItem/ReportActionItemImages.tsx
@@ -9,7 +9,6 @@ import type {ThumbnailAndImageURI} from '@libs/ReceiptUtils';
import variables from '@styles/variables';
-import {useMappingHelper} from '@shopify/flash-list';
import {Str} from 'expensify-common';
import React from 'react';
import {View} from 'react-native';
@@ -53,7 +52,6 @@ function ReportActionItemImages({images, size, total, isHovered = false, onPress
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
- const {getMappingKey} = useMappingHelper();
// Calculate the number of images to be shown, limited by the value of 'size' (if defined)
// or the total number of images.
const numberOfShownImages = Math.min(size ?? images.length, images.length);
@@ -84,7 +82,7 @@ function ReportActionItemImages({images, size, total, isHovered = false, onPress
// Show a border to separate multiple images. Shown to the right for each except the last.
const shouldShowBorder = shownImages.length > 1 && index < shownImages.length - 1;
const borderStyle = shouldShowBorder ? styles.reportActionItemImageBorder : {};
- const key = getMappingKey(image ?? '', index);
+ const key = `${image ?? ''}:${index}`;
return (
) => report?.currency;
-
type ReservationViewProps = {
reservation: Reservation;
onPress?: () => void;
isCancelled?: boolean;
};
-
function ReservationView({reservation, onPress, isCancelled}: ReservationViewProps) {
const theme = useTheme();
const styles = useThemeStyles();
const StyleUtils = useStyleUtils();
const {translate} = useLocalize();
const expensifyIcons = useMemoizedLazyExpensifyIcons(['Plane', 'PlaneCircleSlash', 'Bed', 'BedCircleSlash', 'CarWithKey', 'CarCircleSlash', 'Train', 'TrainCircleSlash', 'Luggage']);
-
const reservationIcon = getTripReservationIcon(expensifyIcons, reservation.type, isCancelled);
const title = reservation.type === CONST.RESERVATION_TYPE.CAR ? reservation.carInfo?.name : Str.recapitalize(reservation.start.longName ?? '');
-
const description = translate(`travel.${reservation.type}`);
-
const cancelledStyle = isCancelled ? styles.textSupporting : undefined;
-
let titleComponent = (
);
-
if (reservation.type === CONST.RESERVATION_TYPE.FLIGHT || reservation.type === CONST.RESERVATION_TYPE.TRAIN) {
const startName = reservation.type === CONST.RESERVATION_TYPE.FLIGHT ? reservation.start.shortName : reservation.start.longName;
const endName = reservation.type === CONST.RESERVATION_TYPE.FLIGHT ? reservation.end.shortName : reservation.end.longName;
-
titleComponent = (
);
}
-
const displayDescription = formatCancelledDescription(translate('iou.canceled'), description, isCancelled);
-
return (
);
}
-
function TripRoomPreview({action, containerStyles, isHovered = false}: TripRoomPreviewProps) {
const styles = useThemeStyles();
const {translate, dateFnsLocale} = useLocalize();
const {convertToDisplayString} = useCurrencyListActions();
const {anchor: contextMenuAnchorRef, shouldDisplayContextMenu = true, originalReportID} = useShowContextMenuState();
const {checkIfContextMenuActive} = useShowContextMenuActions();
-
const originalMessage = getOriginalMessage(action);
const linkedReportID = originalMessage && 'linkedReportID' in originalMessage ? originalMessage.linkedReportID : undefined;
const [chatReport] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${linkedReportID}`);
- const [iouReportCurrency] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReport?.iouReportID}`, {selector: selectCurrency});
-
+ const [iouReportCurrency] = useOnyx(`${ONYXKEYS.COLLECTION.REPORT}${chatReport?.iouReportID}`, {
+ selector: selectCurrency,
+ });
const chatReportID = chatReport?.reportID ?? linkedReportID;
const tripTransactions = useTripTransactions(chatReportID);
-
const reservationsData: ReservationData[] = getReservationsFromTripReport(chatReport, tripTransactions);
const dateInfo =
chatReport?.tripData?.startDate && chatReport?.tripData?.endDate
? DateUtils.getFormattedDateRange(translate, dateFnsLocale, new Date(chatReport.tripData.startDate), new Date(chatReport.tripData.endDate))
: '';
const reportCurrency = iouReportCurrency ?? chatReport?.currency;
-
const {totalDisplaySpend = 0, currency = reportCurrency} = chatReport ? getTripTotal(chatReport) : {};
-
- const displayAmount = useMemo(() => {
- if (totalDisplaySpend) {
- return convertToDisplayString(totalDisplaySpend, currency);
- }
-
- return convertToDisplayString(
- tripTransactions?.reduce((acc, transaction) => acc + Math.abs(transaction.amount), 0),
- currency,
+ const displayAmount = convertToDisplayString(totalDisplaySpend || tripTransactions?.reduce((acc, transaction) => acc + Math.abs(transaction.amount), 0), currency);
+ const navigateToTrip = () =>
+ Navigation.navigate(
+ getReportRouteForCurrentContext({
+ reportID: chatReportID,
+ }),
);
- }, [convertToDisplayString, currency, totalDisplaySpend, tripTransactions]);
-
- const navigateToTrip = () => Navigation.navigate(getReportRouteForCurrentContext({reportID: chatReportID}));
- const renderItem = ({item}: ListRenderItemInfo) => (
+ const renderItem = ({item}: LegendListRenderItemProps) => (
);
-
return (
{reservationsData.length > 0 && (
-
);
}
-
export default TripRoomPreview;
diff --git a/src/components/Search/DeferredSearchAutocompleteList/index.native.tsx b/src/components/Search/DeferredSearchAutocompleteList/index.native.tsx
index a8df569c5b58..dab1e261420c 100644
--- a/src/components/Search/DeferredSearchAutocompleteList/index.native.tsx
+++ b/src/components/Search/DeferredSearchAutocompleteList/index.native.tsx
@@ -43,6 +43,9 @@ function DeferredAutocompleteList(props: SearchAutocompleteListProps) {
shouldStyleAsTable
onLayout={markLayoutComplete}
speed={CONST.TIMING.SKELETON_ANIMATION_SPEED}
+ // At this speed the shimmer's first sweep starts 750ms in, and the list replaces this placeholder
+ // well before that, so it paints flat either way.
+ shouldAnimate={false}
/>
);
}
diff --git a/src/components/Search/ExpenseGroupedSearchView.tsx b/src/components/Search/ExpenseGroupedSearchView.tsx
index 3f1459c89b6d..ee4416975976 100644
--- a/src/components/Search/ExpenseGroupedSearchView.tsx
+++ b/src/components/Search/ExpenseGroupedSearchView.tsx
@@ -44,7 +44,7 @@ const isRowSelected = (key: string | undefined, selectedTransactions: SelectedTr
/**
* On wide web layouts each group is split into a sticky header row plus a children-container row, so the
* rendered list differs from the source data. Returns the rendered list plus the sticky-header and
- * children-container indices FlashList needs. On narrow/native layouts the group rows render as-is.
+ * children-container indices LegendList needs. On narrow/native layouts the group rows render as-is.
*/
function buildSplitGroupData(data: SearchListItem[], shouldSplitGroups: boolean) {
if (!shouldSplitGroups) {
@@ -202,16 +202,7 @@ function ExpenseGroupedSearchView({
return 'default';
};
- const overrideItemLayout = (layout: {size?: number; span?: number}, item: SearchListItem) => {
- if (!isGroupHeaderItem(item)) {
- return;
- }
- // FlashList requires mutating the layout object passed to overrideItemLayout.
- // eslint-disable-next-line no-param-reassign -- FlashList overrideItemLayout API
- layout.size = variables.tableRowHeight;
- };
-
- const stickyHeaderConfig = shouldSplit ? {hideRelatedCell: true, useNativeDriver: true, zIndex: 2} : undefined;
+ const getFixedItemSize = (item: SearchListItem) => (isGroupHeaderItem(item) ? variables.tableRowHeight : undefined);
const renderItem = (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => {
if (isGroupHeaderItem(item)) {
@@ -348,9 +339,8 @@ function ExpenseGroupedSearchView({
nonPersonalAndWorkspaceCards={nonPersonalAndWorkspaceCards}
stickyHeaderIndices={stickyHeaderIndices}
getItemType={getItemType}
- stickyHeaderConfig={stickyHeaderConfig}
disabledIndexes={shouldSplit ? childrenContainerIndices : undefined}
- overrideItemLayout={shouldSplit ? overrideItemLayout : undefined}
+ getFixedItemSize={shouldSplit ? getFixedItemSize : undefined}
/>
{modal}
diff --git a/src/components/Search/FilterDropdowns/DisplayPopup.tsx b/src/components/Search/FilterDropdowns/DisplayPopup.tsx
index fea0dfa50996..4b9970af916f 100644
--- a/src/components/Search/FilterDropdowns/DisplayPopup.tsx
+++ b/src/components/Search/FilterDropdowns/DisplayPopup.tsx
@@ -1,6 +1,6 @@
import CompactMenuContext from '@components/CompactMenuContext';
import MenuItemAction from '@components/MenuItem/presets/MenuItemAction';
-import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
+import MenuItemField from '@components/MenuItem/presets/MenuItemField';
import ScrollView from '@components/ScrollView';
import useUpdateFilterQuery from '@components/Search/hooks/useUpdateFilterQuery';
import type {SearchQueryJSON} from '@components/Search/types';
@@ -80,47 +80,42 @@ function DisplayPopup({queryJSON, searchResults, closeOverlay, onSort}: DisplayP
return (
- setSelectedDisplayFilter(CONST.SEARCH.SYNTAX_ROOT_KEYS.SORT_BY)}
sentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_SORT_BY}
+ value={`${translate(getSearchColumnTranslationKey(sortByValue))} ${CONST.DOT_SEPARATOR} ${translate(`search.filters.sortOrder.${sortOrderValue}`)}`}
/>
{(isExpenseType || isTripType) && (
- setSelectedDisplayFilter(CONST.SEARCH.SYNTAX_ROOT_KEYS.GROUP_BY)}
sentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_GROUP_BY}
+ value={groupByValue ? translate(`search.filters.groupBy.${groupByValue}`) : undefined}
/>
)}
{!!groupBy && (
- setSelectedDisplayFilter(CONST.SEARCH.SYNTAX_FILTER_KEYS.GROUP_CURRENCY)}
sentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_GROUP_CURRENCY}
+ value={groupCurrencyValue}
/>
)}
{isExpenseType && !!groupByValue && (
- setSelectedDisplayFilter(CONST.SEARCH.SYNTAX_ROOT_KEYS.VIEW)}
sentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_VIEW}
+ value={viewValue ? translate(`search.view.${viewValue}`) : undefined}
/>
)}
{isExpenseType && (
- setSelectedDisplayFilter(CONST.SEARCH.SYNTAX_ROOT_KEYS.LIMIT)}
sentryLabel={CONST.SENTRY_LABEL.SEARCH.FILTER_LIMIT}
+ value={limitValue}
/>
)}
{shouldShowColumnsButton && (
diff --git a/src/components/Search/FilterDropdowns/SortByPopup.tsx b/src/components/Search/FilterDropdowns/SortByPopup.tsx
index ab5d17990869..698ad4331211 100644
--- a/src/components/Search/FilterDropdowns/SortByPopup.tsx
+++ b/src/components/Search/FilterDropdowns/SortByPopup.tsx
@@ -1,4 +1,4 @@
-import MenuItemWithTopDescription from '@components/MenuItemWithTopDescription';
+import MenuItemField from '@components/MenuItem/presets/MenuItemField';
import ListFilterWrapper from '@components/Search/FilterComponents/ListFilterViewWrapper';
import type {SingleSelectItem} from '@components/Search/FilterComponents/SingleSelect';
import {useSearchResultsContext, useSearchSelectionActions} from '@components/Search/SearchContext';
@@ -109,11 +109,10 @@ function SortByPopup({searchResults, queryJSON, groupBy, onSort, onSortOrderPres
hasHeader
extraHeight={variables.optionRowHeight + DIVIDER_HEIGHT}
>
-
);
-
function BaseSearchList({
data,
renderItem,
@@ -22,33 +19,32 @@ function BaseSearchList({
contentContainerStyle,
stickyHeaderIndices,
getItemType,
+ getFixedItemSize,
}: BaseSearchListProps) {
- const renderItemWithoutKeyboardFocus = useCallback(
- ({item, index}: {item: SearchListItem; index: number}) => {
- return renderItem(item, index, false, undefined);
- },
- [renderItem],
- );
+ const renderItemWithoutKeyboardFocus = ({item, index}: {item: SearchListItem; index: number}) => {
+ return renderItem(item, index, false, undefined);
+ };
return (
-
);
}
diff --git a/src/components/Search/SearchList/BaseSearchList/index.tsx b/src/components/Search/SearchList/BaseSearchList/index.tsx
index 5f7273b9f6f8..9bde31f49517 100644
--- a/src/components/Search/SearchList/BaseSearchList/index.tsx
+++ b/src/components/Search/SearchList/BaseSearchList/index.tsx
@@ -16,38 +16,15 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import {isModalActiveSelector} from '@src/selectors/Modal';
-import type {GestureResponderEvent, NativeSyntheticEvent, StyleProp, ViewProps, ViewStyle} from 'react-native';
+import type {GestureResponderEvent, NativeSyntheticEvent} from 'react-native';
+import {AnimatedLegendList} from '@legendapp/list/reanimated';
import {useIsFocused} from '@react-navigation/native';
-import {FlashList} from '@shopify/flash-list';
-import React, {useCallback, useEffect, useMemo, useRef} from 'react';
+import React, {useEffect, useRef} from 'react';
import {View} from 'react-native';
-import Animated from 'react-native-reanimated';
import type BaseSearchListProps from './types';
-const AnimatedFlashListComponent = Animated.createAnimatedComponent(FlashList);
-
-type CellRendererComponentProps = ViewProps & {
- ref?: React.Ref;
- style?: StyleProp;
-};
-
-function CellRendererComponent({children, ref, style, ...props}: CellRendererComponentProps) {
- const styles = useThemeStyles();
-
- return (
-
- {children}
-
- );
-}
-
function BaseSearchList({
data,
columns,
@@ -71,22 +48,23 @@ function BaseSearchList({
stickyHeaderConfig,
getItemType,
disabledIndexes,
- overrideItemLayout,
+ getFixedItemSize,
}: BaseSearchListProps) {
+ const styles = useThemeStyles();
const hasKeyBeenPressed = useRef(false);
const isFocused = useIsFocused();
const {focusedCellId, isEditingCell} = useEditingCellState();
const [isModalVisible] = useOnyx(ONYXKEYS.MODAL, {selector: isModalActiveSelector});
- const setHasKeyBeenPressed = useCallback(() => {
+ const setHasKeyBeenPressed = () => {
if (hasKeyBeenPressed.current) {
return;
}
// We need to track whether a key has been pressed to enable focus syncing only if a key has been pressed.
// This is to avoid the default behavior of web showing blue border on click of items after a page refresh.
hasKeyBeenPressed.current = true;
- }, []);
+ };
const [focusedIndex, setFocusedIndex] = useArrowKeyFocusManager({
initialFocusedIndex: -1,
@@ -95,9 +73,6 @@ function BaseSearchList({
onFocusedIndexChange: (index: number) => {
scrollToIndex?.(index);
},
- onArrowUpDownCallback: () => {
- ref?.current?.announceProgrammaticScroll();
- },
setHasKeyBeenPressed,
isFocused,
captureOnInputs: false,
@@ -126,34 +101,31 @@ function BaseSearchList({
const renderItemWithKeyboardFocus = ({item, index}: {item: SearchListItem; index: number}) => {
const isItemFocused = focusedIndex === index;
- return renderItem(item, index, isItemFocused, getOnFocus(index));
+ return {renderItem(item, index, isItemFocused, getOnFocus(index))};
};
- const selectFocusedOption = useCallback(
- (event?: GestureResponderEvent | KeyboardEvent) => {
- // Allow event propagation during cell editing so Enter can trigger TextInput.onSubmitEditing.
- // When not editing, stop propagation to prevent unintended button activation and handle row selection.
- if (isEditingCell) {
- return;
- }
+ const selectFocusedOption = (event?: GestureResponderEvent | KeyboardEvent) => {
+ // Allow event propagation during cell editing so Enter can trigger TextInput.onSubmitEditing.
+ // When not editing, stop propagation to prevent unintended button activation and handle row selection.
+ if (isEditingCell) {
+ return;
+ }
- // If a cell has keyboard focus (via Tab), let the Enter event propagate to trigger the cell's onPress
- if (focusedCellId) {
- return;
- }
+ // If a cell has keyboard focus (via Tab), let the Enter event propagate to trigger the cell's onPress
+ if (focusedCellId) {
+ return;
+ }
- event?.stopPropagation();
+ event?.stopPropagation();
- const focusedItem = data.at(focusedIndex);
+ const focusedItem = data.at(focusedIndex);
- if (!focusedItem) {
- return;
- }
+ if (!focusedItem) {
+ return;
+ }
- onSelectRow(focusedItem);
- },
- [data, focusedCellId, focusedIndex, isEditingCell, onSelectRow],
- );
+ onSelectRow(focusedItem);
+ };
useKeyboardShortcut(CONST.KEYBOARD_SHORTCUTS.ENTER, selectFocusedOption, {
captureOnInputs: true,
@@ -170,13 +142,10 @@ function BaseSearchList({
return () => removeKeyDownPressListener(setHasKeyBeenPressed);
}, [setHasKeyBeenPressed]);
- const extraData = useMemo(
- () => [focusedIndex, columns, newTransactions, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy],
- [focusedIndex, columns, newTransactions, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy],
- );
+ const extraData = [focusedIndex, columns, newTransactions, nonPersonalAndWorkspaceCards, isAttendeesEnabledForMovingPolicy, renderItem];
return (
-
);
}
diff --git a/src/components/Search/SearchList/BaseSearchList/types.ts b/src/components/Search/SearchList/BaseSearchList/types.ts
index c18b7a1719df..dfaafd97018c 100644
--- a/src/components/Search/SearchList/BaseSearchList/types.ts
+++ b/src/components/Search/SearchList/BaseSearchList/types.ts
@@ -4,12 +4,12 @@ import type {ExtendedTargetedEvent} from '@components/SelectionList/ListItem/typ
import type {CardList, Transaction} from '@src/types/onyx';
-import type {FlashListProps, FlashListRef} from '@shopify/flash-list';
+import type {LegendListProps, LegendListRef} from '@legendapp/list/react-native';
import type {RefObject} from 'react';
import type {NativeSyntheticEvent} from 'react-native';
type BaseSearchListProps = Pick<
- FlashListProps,
+ LegendListProps,
| 'onScroll'
| 'contentContainerStyle'
| 'onEndReached'
@@ -21,7 +21,7 @@ type BaseSearchListProps = Pick<
| 'onLayout'
| 'stickyHeaderIndices'
| 'stickyHeaderConfig'
- | 'overrideItemLayout'
+ | 'getFixedItemSize'
> & {
data: SearchListItem[];
renderItem: (item: SearchListItem, index: number, isItemFocused: boolean, onFocus?: (event: NativeSyntheticEvent) => void) => React.JSX.Element;
@@ -37,7 +37,7 @@ type BaseSearchListProps = Pick<
/** The callback, which is run when a row is pressed */
onSelectRow: (item: SearchListItem) => void;
- ref: RefObject | null>;
+ ref: RefObject;
scrollToIndex?: (index: number, animated?: boolean) => void;
/** Precomputed attendee-tracking boolean (derived from policy-for-moving-expenses) */
@@ -46,8 +46,8 @@ type BaseSearchListProps = Pick<
/** Non-personal and workspace cards for triggering re-render via extraData */
nonPersonalAndWorkspaceCards?: CardList;
- /** Function to determine item type for FlashList recycling */
- getItemType?: (item: SearchListItem, index: number) => string | number | undefined;
+ /** Function to determine item type for LegendList recycling */
+ getItemType?: LegendListProps['getItemType'];
/** Indexes to skip during keyboard arrow navigation */
disabledIndexes?: readonly number[];
diff --git a/src/components/Search/SearchPageHeader/SearchFiltersBarNarrow.tsx b/src/components/Search/SearchPageHeader/SearchFiltersBarNarrow.tsx
index 89ded4f2cc97..a866c4086b88 100644
--- a/src/components/Search/SearchPageHeader/SearchFiltersBarNarrow.tsx
+++ b/src/components/Search/SearchPageHeader/SearchFiltersBarNarrow.tsx
@@ -6,8 +6,10 @@ import useThemeStyles from '@hooks/useThemeStyles';
import type {SearchFilter} from '@libs/SearchUIUtils';
import shouldAdjustScroll from '@libs/shouldAdjustScroll';
+import type {LegendListRef} from '@legendapp/list/react-native';
+
+import {LegendList} from '@legendapp/list/react-native';
import React, {useRef} from 'react';
-import {FlatList} from 'react-native';
import type {FilterItem} from './useSearchFiltersBar';
@@ -21,7 +23,7 @@ type SearchFiltersBarNarrowProps = {
function SearchFiltersBarNarrow({queryJSON}: SearchFiltersBarNarrowProps) {
const styles = useThemeStyles();
- const scrollRef = useRef>(null);
+ const scrollRef = useRef(null);
const {filters, hasErrors, shouldShowFiltersBarLoading, clearFilters} = useSearchFiltersBar(queryJSON);
const adjustScroll = (info: {distanceFromEnd: number}) => {
@@ -47,7 +49,7 @@ function SearchFiltersBarNarrow({queryJSON}: SearchFiltersBarNarrowProps) {
}
return (
- hasDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH) || Navigation.getIsFullscreenPreInsertedUnderRHP(),
);
-
const {type, sortBy, sortOrder, groupBy} = queryJSON;
const validGroupBy = getValidGroupBy(groupBy);
const searchData = searchResults?.data;
-
const sortedData = (() => {
if (!searchData) {
return [] as TransactionListItemType[];
}
-
const [filteredData] = getSections({
dateFnsLocale,
type,
@@ -113,7 +113,6 @@ function SearchStaticList({
convertToDisplayString,
reportAttributesDerivedValue: undefined,
});
-
return getSortedSections(type, filteredData, localeCompare, translate, sortBy, sortOrder, validGroupBy)
.filter((item): item is TransactionListItemType => 'transactionID' in item && item.pendingAction !== CONST.RED_BRICK_ROAD_PENDING_ACTION.DELETE)
.slice(0, STATIC_LIST_MAX_ITEMS);
@@ -121,22 +120,17 @@ function SearchStaticList({
// Sync the pending-expense placeholder on focus and notify the parent that
// the destination is visible (focus signal for the dual-gate span ending).
- useFocusEffect(
- useCallback(() => {
- const hasPendingWrite = hasDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH);
- if (!showPendingExpensePlaceholder && hasPendingWrite) {
- setShowPendingExpensePlaceholder(true);
- } else if (showPendingExpensePlaceholder && !hasPendingWrite && sortedData.length > 0) {
- setShowPendingExpensePlaceholder(false);
- }
-
- onDestinationVisible?.(sortedData.length === 0, 'focus');
- }, [showPendingExpensePlaceholder, sortedData.length, onDestinationVisible]),
- );
-
+ useFocusEffect(() => {
+ const hasPendingWrite = hasDeferredWrite(CONST.DEFERRED_LAYOUT_WRITE_KEYS.SEARCH);
+ if (!showPendingExpensePlaceholder && hasPendingWrite) {
+ setShowPendingExpensePlaceholder(true);
+ } else if (showPendingExpensePlaceholder && !hasPendingWrite && sortedData.length > 0) {
+ setShowPendingExpensePlaceholder(false);
+ }
+ onDestinationVisible?.(sortedData.length === 0, 'focus');
+ });
const onPressItem = (item: TransactionListItemType) => {
const backTo = Navigation.getActiveRoute();
-
if (!item.reportAction?.childReportID) {
const shouldOpenTransactionThread = !isOneTransactionReport(item.report) || item.reportID === CONST.REPORT.UNREPORTED_REPORT_ID;
// betas, introSelected and conciergeChat are passed as undefined to avoid extra Onyx subscriptions in this lightweight placeholder.
@@ -161,39 +155,48 @@ function SearchStaticList({
return;
}
}
-
const isFromSelfDM = item.reportID === CONST.REPORT.UNREPORTED_REPORT_ID;
const isFromOneTransactionReport = isOneTransactionReport(item.report);
-
let reportID = item.reportID;
if (item.reportAction?.childReportID && (isFromSelfDM || !isFromOneTransactionReport)) {
reportID = item.reportAction.childReportID;
}
-
if (!reportID) {
return;
}
-
- requestAnimationFrame(() => Navigation.navigate(ROUTES.SEARCH_REPORT.getRoute({reportID, backTo})));
+ requestAnimationFrame(() =>
+ Navigation.navigate(
+ ROUTES.SEARCH_REPORT.getRoute({
+ reportID,
+ backTo,
+ }),
+ ),
+ );
};
-
- const renderItem = ({item, index}: ListRenderItemInfo) => {
+ const renderItem = ({item, index}: LegendListRenderItemProps) => {
if (!('transactionID' in item)) {
return null;
}
-
const participantFromDisplayName = item.formattedFrom ?? item.from?.displayName ?? '';
const shouldShowUserInfo = !!item.from;
const isFirstItem = index === 0;
const isLastItem = index === sortedData.length - 1;
-
const stateNum = item.report?.stateNum;
const statusNum = item.report?.statusNum;
const isDeleted = isDeletedTransaction(item);
- const statusText = getReportStatusTranslation({stateNum, statusNum, translate, isDeleted});
+ const statusText = getReportStatusTranslation({
+ stateNum,
+ statusNum,
+ translate,
+ isDeleted,
+ });
const reportStatusColorStyle = getReportStatusColorStyle(theme, stateNum, statusNum, isDeleted);
- const statusTooltipText = getReportStatusTooltipTranslation({stateNum, statusNum, translate, isDeleted});
-
+ const statusTooltipText = getReportStatusTooltipTranslation({
+ stateNum,
+ statusNum,
+ translate,
+ isDeleted,
+ });
return (
);
};
-
const hasWideFooter = !shouldUseNarrowLayout || showPendingExpensePlaceholder;
-
- const renderWideItem = ({item, index}: ListRenderItemInfo, dataLength: number) => {
+ const renderWideItem = ({item, index}: LegendListRenderItemProps, dataLength: number) => {
if (!('transactionID' in item)) {
return null;
}
const isLastItem = index === dataLength - 1 && !hasWideFooter;
-
return (
);
};
-
const keyExtractor = (item: TransactionListItemType) => item.keyForList;
-
const hasEndedSpanRef = useRef(false);
const onLayout = () => {
if (hasEndedSpanRef.current) {
return;
}
hasEndedSpanRef.current = true;
-
onDestinationVisible?.(sortedData.length === 0, 'layout');
onLayoutProp?.();
};
-
if (sortedData.length === 0 && showPendingExpensePlaceholder) {
return (
);
}
-
if (sortedData.length === 0) {
return ;
}
-
return (
)}
- renderWideItem(info, sortedData.length)}
keyExtractor={keyExtractor}
showsVerticalScrollIndicator={false}
contentContainerStyle={shouldUseNarrowLayout ? contentContainerStyle : styles.pb3}
- removeClippedSubviews
ListFooterComponent={
showPendingExpensePlaceholder ? (
);
}
-
SearchStaticList.displayName = 'SearchStaticList';
-
export default SearchStaticList;
diff --git a/src/components/Search/hooks/useSearchListViewState.ts b/src/components/Search/hooks/useSearchListViewState.ts
index c7f94336779b..076b7f140592 100644
--- a/src/components/Search/hooks/useSearchListViewState.ts
+++ b/src/components/Search/hooks/useSearchListViewState.ts
@@ -21,7 +21,7 @@ import CONST from '@src/CONST';
import ONYXKEYS from '@src/ONYXKEYS';
import type {Transaction} from '@src/types/onyx';
-import type {FlashListRef} from '@shopify/flash-list';
+import type {LegendListRef} from '@legendapp/list/react-native';
import {useRef} from 'react';
@@ -65,7 +65,7 @@ function useSearchListViewState({data, listData = data, isMobileSelectionModeEna
const {isSmallScreenWidth, isLargeScreenWidth} = useResponsiveLayout();
const {isEditingCell, wasRecentlyEditingCell} = useEditingCellState();
- const listRef = useRef>(null);
+ const listRef = useRef(null);
const prevDataLength = usePrevious(data.length);
const hasItemsBeingRemoved = !!prevDataLength && prevDataLength > data.length;
diff --git a/src/components/Search/primitives/useScrollRestoration.ts b/src/components/Search/primitives/useScrollRestoration.ts
index bde273561304..373d2243a45b 100644
--- a/src/components/Search/primitives/useScrollRestoration.ts
+++ b/src/components/Search/primitives/useScrollRestoration.ts
@@ -1,34 +1,32 @@
import {ScrollOffsetContext} from '@components/ScrollOffsetContextProvider';
-import type {FlashListRef} from '@shopify/flash-list';
+import type {LegendListRef} from '@legendapp/list/react-native';
import type {RefObject} from 'react';
import {useFocusEffect, useRoute} from '@react-navigation/native';
-import {useCallback, useContext} from 'react';
+import {useContext} from 'react';
/**
* Restores the Search list's vertical scroll position when the screen regains focus.
*
* The offset is saved per route in ScrollOffsetContext by the page wrappers; on focus we read it back
- * and apply it to the FlashList on the next frame, so a back-navigation lands at the prior position
+ * and apply it to the LegendList on the next frame, so a back-navigation lands at the prior position
* instead of the top. Extracted from SearchList so ExpenseFlatSearchView can reuse it.
*/
-function useScrollRestoration(listRef: RefObject | null>) {
+function useScrollRestoration(listRef: RefObject) {
const route = useRoute();
const {getScrollOffset} = useContext(ScrollOffsetContext);
- useFocusEffect(
- useCallback(() => {
- const offset = getScrollOffset(route);
- requestAnimationFrame(() => {
- if (!offset || !listRef.current) {
- return;
- }
+ useFocusEffect(() => {
+ const offset = getScrollOffset(route);
+ requestAnimationFrame(() => {
+ if (!offset || !listRef.current) {
+ return;
+ }
- listRef.current.scrollToOffset({offset, animated: false});
- });
- }, [getScrollOffset, route, listRef]),
- );
+ listRef.current.scrollToOffset({offset, animated: false});
+ });
+ });
}
export default useScrollRestoration;
diff --git a/src/components/SelectionList/BaseSelectionList.tsx b/src/components/SelectionList/BaseSelectionList.tsx
index 262c8b077221..6ddf28cffcb5 100644
--- a/src/components/SelectionList/BaseSelectionList.tsx
+++ b/src/components/SelectionList/BaseSelectionList.tsx
@@ -7,12 +7,12 @@ import useThemeStyles from '@hooks/useThemeStyles';
import CONST from '@src/CONST';
import getEmptyArray from '@src/types/utils/getEmptyArray';
-import type {FlashListRef, ListRenderItem, ListRenderItemInfo} from '@shopify/flash-list';
+import type {LegendListProps, LegendListRef, LegendListRenderItemProps} from '@legendapp/list/react-native';
+import {LegendList} from '@legendapp/list/react-native';
import {useIsFocused} from '@react-navigation/native';
-import {FlashList} from '@shopify/flash-list';
import {deepEqual} from 'fast-equals';
-import React, {useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState} from 'react';
+import React, {useEffect, useImperativeHandle, useRef, useState} from 'react';
import {Keyboard, View} from 'react-native';
import type {DataDetailsType, ListItem, SelectionListProps} from './types';
@@ -112,61 +112,21 @@ function BaseSelectionListImpl({
// Kept out of the destructuring default so the `!!` doesn't bail the component out of React Compiler.
const shouldShowTextInput = shouldShowTextInputProp ?? !!textInputOptions?.label;
- const listRef = useRef | null>(null);
+ const listRef = useRef(null);
const {scrollToIndex, debouncedScrollToIndex} = useSelectionListScroll(listRef, data);
const itemFocusTimeoutRef = useRef(null);
const keyboardListenerRef = useRef | null>(null);
- const initialFocusedIndex = useMemo(() => data.findIndex((i) => i.keyForList === initiallyFocusedItemKey), [data, initiallyFocusedItemKey]);
+ const initialFocusedIndex = data.findIndex((i) => i.keyForList === initiallyFocusedItemKey);
const [itemsToHighlight, setItemsToHighlight] = useState | null>(null);
- const isItemSelected = useCallback(
- (item: ListItem) => item.isSelected ?? ((isSelected?.(item) ?? selectedItems.includes(item.keyForList)) && canSelectMultiple),
- [isSelected, selectedItems, canSelectMultiple],
- );
+ const isItemSelected = (item: ListItem) => item.isSelected ?? ((isSelected?.(item) ?? selectedItems.includes(item.keyForList)) && canSelectMultiple);
- const paddingBottomStyle = useMemo(() => !isKeyboardShown && safeAreaPaddingBottomStyle, [isKeyboardShown, safeAreaPaddingBottomStyle]);
+ const paddingBottomStyle = !isKeyboardShown && safeAreaPaddingBottomStyle;
const hasFooter = !!footerContent || confirmButtonOptions?.showButton;
- const dataDetails = useMemo>(() => {
- const {disabledIndexes, disabledArrowKeyIndexes, selectedOptions, disabledSelectedIndexes} = data.reduce(
- (acc: {disabledIndexes: number[]; disabledArrowKeyIndexes: number[]; selectedOptions: ListItem[]; disabledSelectedIndexes: number[]}, item: ListItem, index: number) => {
- const idx = item.index ?? index;
- const itemIsSelected = isItemSelected(item);
- const isItemDisabled = isDisabled || (!!item?.isDisabled && !itemIsSelected);
- const isEffectivelyDisabled = isItemDisabled || !!item?.isDisabledCheckbox;
-
- if (itemIsSelected && (canSelectMultiple || acc.selectedOptions.length === 0)) {
- acc.selectedOptions.push(item);
- }
-
- if (!isEffectivelyDisabled) {
- return acc;
- }
-
- acc.disabledIndexes.push(idx);
-
- if (isItemDisabled) {
- acc.disabledArrowKeyIndexes.push(idx);
- }
-
- if (itemIsSelected) {
- acc.disabledSelectedIndexes.push(idx);
- }
-
- return acc;
- },
- {disabledIndexes: [], disabledArrowKeyIndexes: [], selectedOptions: [], disabledSelectedIndexes: []},
- );
-
- const totalSelectable = data.length - disabledIndexes.length;
- const selectableSelectedCount = selectedOptions.length - disabledSelectedIndexes.length;
- const allSelected = selectableSelectedCount > 0 && selectableSelectedCount === totalSelectable;
- const someSelected = selectableSelectedCount > 0 && selectableSelectedCount < totalSelectable;
-
- return {data, allSelected, someSelected, selectedOptions, disabledIndexes, disabledArrowKeyIndexes};
- }, [canSelectMultiple, data, isDisabled, isItemSelected]);
+ const dataDetails = getDataDetails({data, canSelectMultiple, isDisabled, isItemSelected});
const {focusedIndex, setFocusedIndex, isKeyboardNavigating, setHasKeyBeenPressed} = useSelectionListKeyboardFocus({
initialFocusedIndex,
@@ -178,68 +138,39 @@ function BaseSelectionListImpl({
shouldDebounceScrolling,
scrollToIndex,
debouncedScrollToIndex,
- announceProgrammaticScroll: () => listRef.current?.announceProgrammaticScroll(),
setShouldDisableHoverStyle,
});
const {innerTextInputRef, isTextInputFocusedRef, focusTextInput, textInputKeyPress} = useSelectionListTextInput(setHasKeyBeenPressed);
- // extraData helps FlashList detect when data changes significantly (e.g., during filtering)
- // Including data.length ensures FlashList resets its layout cache when the list size changes
- // This prevents "index out of bounds" errors when filtering reduces the list size
- const extraData = useMemo(() => [data.length], [data.length]);
const syncedSearchValue = searchValueForFocusSync ?? textInputOptions?.value;
- const selectRow = useCallback(
- (item: ListItem, indexToFocus?: number) => {
- if (!isFocused) {
- return;
- }
- if (canSelectMultiple) {
- if (shouldShowTextInput && shouldClearInputOnSelect) {
- textInputOptions?.onChangeText?.('');
- } else if (isSmallScreenWidth) {
- if (!item.isDisabledCheckbox) {
- onSelectionButtonPress?.(item);
- }
- return;
+ const selectRow = (item: ListItem, indexToFocus?: number) => {
+ if (!isFocused) {
+ return;
+ }
+ if (canSelectMultiple) {
+ if (shouldShowTextInput && shouldClearInputOnSelect) {
+ textInputOptions?.onChangeText?.('');
+ } else if (isSmallScreenWidth) {
+ if (!item.isDisabledCheckbox) {
+ onSelectionButtonPress?.(item);
}
+ return;
}
- if (shouldUpdateFocusedIndex && typeof indexToFocus === 'number') {
- setFocusedIndex(indexToFocus);
- }
- onSelectRow(item);
-
- if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow) {
- focusTextInput();
- }
- },
- [
- isFocused,
- canSelectMultiple,
- shouldUpdateFocusedIndex,
- onSelectRow,
- shouldShowTextInput,
- shouldClearInputOnSelect,
- shouldPreventDefaultFocusOnSelectRow,
- isSmallScreenWidth,
- textInputOptions,
- onSelectionButtonPress,
- setFocusedIndex,
- focusTextInput,
- ],
- );
-
- const focusedOption = useMemo(() => {
- if (focusedIndex < 0 || focusedIndex >= data.length) {
- return;
}
- const option = data.at(focusedIndex);
- if (!option || (option.isDisabled && !isItemSelected(option))) {
- return;
+ if (shouldUpdateFocusedIndex && typeof indexToFocus === 'number') {
+ setFocusedIndex(indexToFocus);
+ }
+ onSelectRow(item);
+
+ if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow) {
+ focusTextInput();
}
- return option;
- }, [data, focusedIndex, isItemSelected]);
+ };
+
+ const focusedOptionCandidate = focusedIndex >= 0 && focusedIndex < data.length ? data.at(focusedIndex) : undefined;
+ const focusedOption = focusedOptionCandidate && (!focusedOptionCandidate.isDisabled || isItemSelected(focusedOptionCandidate)) ? focusedOptionCandidate : undefined;
const selectFocusedOption = () => {
if (!focusedOption || focusedOption.isInteractive === false) {
@@ -248,12 +179,26 @@ function BaseSelectionListImpl({
selectRow(focusedOption);
};
+ const hasSelectedItems = dataDetails.selectedOptions.length > 0;
+ const isFooterConfirmEnabled = confirmButtonOptions?.isFooterConfirmEnabled ?? hasSelectedItems;
+ const isCustomFooterConfirmEnabled = isFooterConfirmEnabled && confirmButtonOptions?.isDisabled !== true && confirmButtonOptions?.isFooterConfirmEnterKeyEnabled !== false;
+ // Whether Enter should trigger an enabled confirm button instead of the list.
+ // Footer renders footerContent in place of the built-in button, so the two paths are mutually
+ // exclusive; custom footers count only if they are Enter-capable and enabled. Owners can
+ // override the enabled state when selection persists outside the currently rendered rows.
+ const hasEnabledEnterConfirm =
+ (!footerContent && !!confirmButtonOptions?.showButton && !confirmButtonOptions?.isDisabled) || (!!footerContent && !!confirmButtonOptions?.onConfirm && isCustomFooterConfirmEnabled);
+ // Whether the focused row should handle plain Enter.
+ // Enter selects the row when keyboard navigation/search is active, propagation should stop,
+ // or there is no enabled Enter-capable confirm control that should handle the keypress instead.
+ const shouldSelectOnEnter = isKeyboardNavigating || !!syncedSearchValue?.trim() || !hasEnabledEnterConfirm || shouldStopPropagation;
+
useSelectionListShortcuts({
selectFocusedItem: selectFocusedOption,
getFocusedOption: () => focusedOption,
confirmButtonOptions,
isActive: isFocused,
- focusedIndex,
+ focusedIndex: shouldSelectOnEnter ? focusedIndex : -1,
disableKeyboardShortcuts,
shouldStopPropagation,
shouldBubble: !focusedOption,
@@ -282,7 +227,7 @@ function BaseSelectionListImpl({
);
};
- const renderItem: ListRenderItem = ({item, index}: ListRenderItemInfo) => {
+ const renderItem: NonNullable['renderItem']> = ({item, index}: LegendListRenderItemProps) => {
const selected = isItemSelected(item);
const isItemDisabled = isDisabled || (!!item.isDisabled && !selected);
const isItemFocused = (!isDisabled || selected) && focusedIndex === index;
@@ -349,87 +294,78 @@ function BaseSelectionListImpl({
// The function scrolls to the focused input to prevent keyboard occlusion.
// It ensures the entire list item is visible, not just the input field.
// Added specifically for SplitExpensePage
- const scrollToFocusedInput = useCallback(
- (item: ListItem) => {
- if (!listRef.current) {
+ const scrollToFocusedInput = (item: ListItem) => {
+ if (!listRef.current) {
+ return;
+ }
+
+ // Clear any existing timer and listener before starting new ones
+ if (scrollTimeoutRef.current) {
+ clearTimeout(scrollTimeoutRef.current);
+ }
+ if (keyboardListenerRef.current) {
+ keyboardListenerRef.current.remove();
+ }
+
+ const performScroll = () => {
+ const index = data.findIndex((dataItem) => dataItem.keyForList === item.keyForList);
+ if (index === -1) {
return;
}
+ // Use scrollToIndex with viewPosition 0.5 to center the item in the visible area
+ // This ensures the item is visible above the keyboard
+ listRef.current?.scrollToIndex({index, animated: true, viewPosition: 0.5});
+ };
- // Clear any existing timer and listener before starting new ones
+ // Wait for keyboard to fully appear, then scroll
+ keyboardListenerRef.current = Keyboard.addListener('keyboardDidShow', () => {
+ keyboardListenerRef.current?.remove();
+ keyboardListenerRef.current = null;
+ // Clear fallback timeout since keyboard event fired
if (scrollTimeoutRef.current) {
clearTimeout(scrollTimeoutRef.current);
+ scrollTimeoutRef.current = null;
}
- if (keyboardListenerRef.current) {
- keyboardListenerRef.current.remove();
- }
-
- const performScroll = () => {
- const index = data.findIndex((dataItem) => dataItem.keyForList === item.keyForList);
- if (index === -1) {
- return;
- }
- // Use scrollToIndex with viewPosition 0.5 to center the item in the visible area
- // This ensures the item is visible above the keyboard
- listRef.current?.scrollToIndex({index, animated: true, viewPosition: 0.5});
- };
-
- // Wait for keyboard to fully appear, then scroll
- keyboardListenerRef.current = Keyboard.addListener('keyboardDidShow', () => {
- keyboardListenerRef.current?.remove();
- keyboardListenerRef.current = null;
- // Clear fallback timeout since keyboard event fired
- if (scrollTimeoutRef.current) {
- clearTimeout(scrollTimeoutRef.current);
- scrollTimeoutRef.current = null;
- }
- // Add small delay after keyboard is shown for layout to settle
- scrollTimeoutRef.current = setTimeout(performScroll, CONST.ANIMATION_IN_TIMING);
- });
-
- // Fallback timeout in case keyboard event doesn't fire (e.g., keyboard already open)
- scrollTimeoutRef.current = setTimeout(() => {
- keyboardListenerRef.current?.remove();
- keyboardListenerRef.current = null;
- performScroll();
- }, CONST.ANIMATED_TRANSITION);
- },
- [data],
- );
+ // Add small delay after keyboard is shown for layout to settle
+ scrollTimeoutRef.current = setTimeout(performScroll, CONST.ANIMATION_IN_TIMING);
+ });
+
+ // Fallback timeout in case keyboard event doesn't fire (e.g., keyboard already open)
+ scrollTimeoutRef.current = setTimeout(() => {
+ keyboardListenerRef.current?.remove();
+ keyboardListenerRef.current = null;
+ performScroll();
+ }, CONST.ANIMATED_TRANSITION);
+ };
- const scrollAndHighlightItem = useCallback(
- (items: string[]) => {
- const newItemsToHighlight = new Set(items);
+ const scrollAndHighlightItem = (items: string[]) => {
+ const newItemsToHighlight = new Set(items);
- if (deepEqual(itemsToHighlight, newItemsToHighlight)) {
- return;
- }
+ if (deepEqual(itemsToHighlight, newItemsToHighlight)) {
+ return;
+ }
- const index = data.findIndex((option) => newItemsToHighlight.has(option.keyForList));
- scrollToIndex(index);
- setItemsToHighlight(newItemsToHighlight);
+ const index = data.findIndex((option) => newItemsToHighlight.has(option.keyForList));
+ scrollToIndex(index);
+ setItemsToHighlight(newItemsToHighlight);
- if (itemFocusTimeoutRef.current) {
- clearTimeout(itemFocusTimeoutRef.current);
- }
- itemFocusTimeoutRef.current = setTimeout(() => {
- setItemsToHighlight(null);
- }, ANIMATED_HIGHLIGHT_DURATION);
- },
- [data, itemsToHighlight, scrollToIndex],
- );
+ if (itemFocusTimeoutRef.current) {
+ clearTimeout(itemFocusTimeoutRef.current);
+ }
+ itemFocusTimeoutRef.current = setTimeout(() => {
+ setItemsToHighlight(null);
+ }, ANIMATED_HIGHLIGHT_DURATION);
+ };
- const updateFocusedIndex = useCallback(
- (newFocusedIndex: number, shouldScroll = false) => {
- if (newFocusedIndex < 0 || newFocusedIndex >= data.length) {
- return;
- }
- setFocusedIndex(newFocusedIndex);
- if (shouldScroll) {
- scrollToIndex(newFocusedIndex);
- }
- },
- [data.length, scrollToIndex, setFocusedIndex],
- );
+ const updateFocusedIndex = (newFocusedIndex: number, shouldScroll = false) => {
+ if (newFocusedIndex < 0 || newFocusedIndex >= data.length) {
+ return;
+ }
+ setFocusedIndex(newFocusedIndex);
+ if (shouldScroll) {
+ scrollToIndex(newFocusedIndex);
+ }
+ };
useSelectedItemFocusSync({
data,
@@ -458,12 +394,12 @@ function BaseSelectionListImpl({
clearTimeout(itemFocusTimeoutRef.current);
}, []);
- const handleSelectAll = useCallback(() => {
+ const handleSelectAll = () => {
onSelectAll?.();
if (shouldShowTextInput && shouldPreventDefaultFocusOnSelectRow) {
focusTextInput();
}
- }, [onSelectAll, shouldShowTextInput, shouldPreventDefaultFocusOnSelectRow, focusTextInput]);
+ };
useImperativeHandle(ref, () => ({scrollAndHighlightItem, scrollToIndex, updateFocusedIndex, scrollToFocusedInput, focusTextInput}), [
focusTextInput,
@@ -502,13 +438,13 @@ function BaseSelectionListImpl({
) : (
<>
{!shouldHeaderBeInsideList && header}
- item.keyForList}
- extraData={extraData}
+ extraData={renderItem}
ListFooterComponent={listFooterContent}
ListFooterComponentStyle={style?.listFooterContentStyle}
scrollEnabled={scrollEnabled}
@@ -522,7 +458,7 @@ function BaseSelectionListImpl({
contentContainerStyle={[styles.pb3, style?.contentContainerStyle]}
initialScrollIndex={shouldScrollToFocusedIndexOnMount ? initialFocusedIndex : undefined}
onScrollBeginDrag={onScrollBeginDrag}
- maintainVisibleContentPosition={{disabled: disableMaintainingScrollPosition}}
+ maintainVisibleContentPosition={!disableMaintainingScrollPosition}
ListHeaderComponent={
<>
{customListHeaderContent}
@@ -548,4 +484,53 @@ function BaseSelectionList(props: SelectionListProps