Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -112,7 +112,7 @@
"@react-ng/bounds-observer": "^0.2.1",
"@rnmapbox/maps": "10.1.44",
"@sentry/react-native": "8.2.0",
"@shopify/flash-list": "2.2.0",
"@shopify/flash-list": "^2.3.0",
"@shopify/react-native-skia": "^2.4.14",
"@ua/react-native-airship": "~25.0.0",
"array.prototype.tosorted": "^1.1.4",
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
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..ea4eba2 100644
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 {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
index 8b75322..8ea7795 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 = <T,>(

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
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
diff --git a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
index 8ea7795..205069b 100644
--- a/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
+++ b/node_modules/@shopify/flash-list/dist/recyclerview/RecyclerView.js
@@ -138,9 +138,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 = <T,>(

// 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(
16 changes: 15 additions & 1 deletion patches/@shopify/flash-list/details.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# `@shopify/flash-list` patches

### [@shopify+flash-list+2.2.0.patch](@shopify+flash-list+2.2.0.patch)
### [@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.
Expand All @@ -9,3 +9,17 @@
- Upstream PR/issue: TBD
- 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+fix-inverted-scroll-direction-on-web.patch](@shopify+flash-list+2.3.0+002+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: TBD
- PR introducing patch: TBD

### [@shopify+flash-list+2.3.0+003+fix-inverted-first-item-offset.patch](@shopify+flash-list+2.3.0+003+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: TBD
- E/App issue: TBD
- PR introducing patch: TBD
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import React from 'react';
import type {StyleProp, ViewProps, ViewStyle} from 'react-native';
import {View} from 'react-native';

type CellRendererComponentProps = ViewProps & {
index: number;
style?: StyleProp<ViewStyle>;
};

function CellRendererComponent(props: CellRendererComponentProps) {
return (
<View
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
style={[
props.style,
/**
* To achieve absolute positioning and handle overflows for list items,
* it is necessary to assign zIndex values. In the case of inverted lists,
* the lower list items will have higher zIndex values compared to the upper
* list items. Consequently, lower list items can overflow the upper list items.
* See: https://github.com/Expensify/App/issues/20451
*/
{zIndex: -props.index},
]}
/>
);
}

export default CellRendererComponent;
17 changes: 17 additions & 0 deletions src/components/FlashList/InvertedFlashList/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import type {FlashListProps} from '@shopify/flash-list';
import React from 'react';
import FlashList from '..';
import CellRendererComponent from './CellRendererComponent';

function InvertedFlashList<T>(props: FlashListProps<T>) {
return (
<FlashList<T>
// eslint-disable-next-line react/jsx-props-no-spreading
{...props}
inverted
CellRendererComponent={CellRendererComponent}
/>
);
}

export default InvertedFlashList;
29 changes: 29 additions & 0 deletions src/components/FlashList/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import {FlashList as ShopifyFlashList} from '@shopify/flash-list';
import type {FlashListProps} from '@shopify/flash-list';
import React, {useCallback} from 'react';
import type {NativeScrollEvent, NativeSyntheticEvent} from 'react-native';
import useEmitComposerScrollEvents from '@hooks/useEmitComposerScrollEvents';

function FlashList<T>({onScroll: onScrollProp, inverted, ...restProps}: FlashListProps<T>) {
const emitComposerScrollEvents = useEmitComposerScrollEvents({enabled: true, inverted});

const handleScroll = useCallback(
(e: NativeSyntheticEvent<NativeScrollEvent>) => {
onScrollProp?.(e);
// Emit scroll events so that ActiveHoverable can suppress hover effects during scroll
emitComposerScrollEvents();
},
[emitComposerScrollEvents, onScrollProp],
);

return (
<ShopifyFlashList<T>
// eslint-disable-next-line react/jsx-props-no-spreading
{...restProps}
inverted={inverted}
onScroll={handleScroll}
/>
);
}

export default FlashList;
Loading
Loading