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.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..7c36bc4e14af 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",
@@ -125,7 +124,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 +16963,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",
@@ -35843,9 +35831,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..deb448cf7a10 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",
@@ -201,7 +200,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/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 (
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/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/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/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.ios.tsx b/src/components/FlatList/FlatList/index.ios.tsx
index 5fc8e9b546a7..05bfe439c045 100644
--- a/src/components/FlatList/FlatList/index.ios.tsx
+++ b/src/components/FlatList/FlatList/index.ios.tsx
@@ -7,7 +7,7 @@ import useThemeStyles from '@hooks/useThemeStyles';
import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native';
-import React, {useCallback, useRef, useState} from 'react';
+import {useRef, useState} from 'react';
import {FlatList} from 'react-native';
import type {CustomFlatListProps} from './types';
@@ -26,30 +26,21 @@ function CustomFlatList({
}: CustomFlatListProps) {
const [isScrolling, setIsScrolling] = useState(false);
const styles = useThemeStyles();
- const handleScrollBegin = useCallback(
- (event: NativeSyntheticEvent) => {
- onMomentumScrollBegin?.(event);
- setIsScrolling(true);
- },
- [onMomentumScrollBegin],
- );
+ const handleScrollBegin = (event: NativeSyntheticEvent) => {
+ onMomentumScrollBegin?.(event);
+ setIsScrolling(true);
+ };
- const handleScrollEnd = useCallback(
- (event: NativeSyntheticEvent) => {
- onMomentumScrollEnd?.(event);
- setIsScrolling(false);
- },
- [onMomentumScrollEnd],
- );
+ const handleScrollEnd = (event: NativeSyntheticEvent) => {
+ onMomentumScrollEnd?.(event);
+ setIsScrolling(false);
+ };
- const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !enableAnimatedKeyboardDismissal, inverted: restProps.inverted});
- const handleScroll = useCallback(
- (e: NativeSyntheticEvent) => {
- onScrollProp?.(e);
- emitComposerScrollEvents();
- },
- [emitComposerScrollEvents, onScrollProp],
- );
+ const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !enableAnimatedKeyboardDismissal && !!restProps.inverted});
+ const handleScroll = (e: NativeSyntheticEvent) => {
+ onScrollProp?.(e);
+ emitComposerScrollEvents();
+ };
const listRef = useRef | null>(null);
useFlatListHandle({
diff --git a/src/components/FlatList/FlatList/index.tsx b/src/components/FlatList/FlatList/index.tsx
index fb977c60a232..9c13ff4c7068 100644
--- a/src/components/FlatList/FlatList/index.tsx
+++ b/src/components/FlatList/FlatList/index.tsx
@@ -245,7 +245,7 @@ function MVCPFlatList({
};
}, []);
- const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted: restProps.inverted});
+ const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: !!restProps.inverted});
const handleScroll = useCallback(
(e: NativeSyntheticEvent) => {
onScrollProp?.(e);
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')}
-