Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
52 commits
Select commit Hold shift + click to select a range
e13e356
Update FlashList lib to v2.3.0 so it has an inverted flag. Add a patc…
VickyStash Mar 12, 2026
09217f2
Improve the patch
VickyStash Mar 12, 2026
4fe493b
Add a custom FlashList component
VickyStash Mar 12, 2026
2e4ff79
Fix items display
VickyStash Mar 12, 2026
286f006
Add a patch to fix first item offset calculation
VickyStash Mar 12, 2026
d56d2fa
Fix the patches
VickyStash Mar 12, 2026
f48e2ca
Fix CellRendererComponent
VickyStash Mar 12, 2026
018b32d
Update ReportActionsList to use InvertedFlashList
VickyStash Mar 12, 2026
db1bf8a
Merge branch 'main' into VickyStash/refactor/33725-flashlist-with-inv…
VickyStash Mar 18, 2026
197389e
Fix chat opening at the linked report action
VickyStash Mar 18, 2026
b720cdd
Restore shouldScrollToEndAfterLayout logic
VickyStash Mar 18, 2026
84b383a
Add TODOs
VickyStash Mar 18, 2026
435f776
Fix items overlapping after chat initial loading finished
VickyStash Mar 19, 2026
bbbc198
Merge branch 'main' into VickyStash/refactor/33725-flashlist-with-inv…
VickyStash Mar 19, 2026
6362b59
Remove shouldEnableAutoScrollToTopThreshold
VickyStash Mar 19, 2026
d4e18e1
Merge branch 'main' into VickyStash/refactor/33725-flashlist-with-inv…
VickyStash Mar 20, 2026
81d712b
Merge branch 'main' into VickyStash/refactor/33725-flashlist-with-inv…
VickyStash Mar 20, 2026
29bc873
Re-apply patches
VickyStash Mar 20, 2026
c7e2cf5
Re-apply patches - correct order
VickyStash Mar 20, 2026
87ed71a
Merge branch 'main' into VickyStash/refactor/33725-flashlist-with-inv…
VickyStash Mar 24, 2026
87bedd8
Fix PaginationTest.tsx
VickyStash Mar 24, 2026
75abf62
Return test to initial state
VickyStash Mar 24, 2026
83e022e
Fix
VickyStash Mar 24, 2026
90b3623
Revert "Fix"
VickyStash Mar 24, 2026
27e2922
Add mock to ReportActionsList.perf-test.tsx
VickyStash Mar 24, 2026
3bd3673
Experiment
VickyStash Mar 24, 2026
00ff7cc
Merge branch 'main' into VickyStash/refactor/33725-flashlist-with-inv…
VickyStash Apr 1, 2026
1c4f9f2
Clean up after conflicts resolution
VickyStash Apr 1, 2026
97b2556
Get rid of shouldHideContent flag
VickyStash Apr 1, 2026
ae4beef
Fix
VickyStash Apr 1, 2026
408b488
Fix jest test
VickyStash Apr 1, 2026
ec435f7
Put mock in one place
VickyStash Apr 1, 2026
7af2fef
Merge branch 'main' into VickyStash/refactor/33725-flashlist-with-inv…
VickyStash Apr 1, 2026
58f64ec
Merge branch 'main' into VickyStash/refactor/33725-flashlist-with-inv…
VickyStash Apr 2, 2026
aab763d
Merge branch 'main' into VickyStash/refactor/33725-flashlist-with-inv…
VickyStash Apr 3, 2026
56831c6
Resolve TODO
VickyStash Apr 3, 2026
cdfda95
Add getItemType prop
VickyStash Apr 3, 2026
281a0ea
Fix jump when open a chat for the first time at linked position in th…
VickyStash Apr 3, 2026
1d73ee3
Merge branch 'main' into VickyStash/refactor/33725-flashlist-with-inv…
VickyStash Apr 7, 2026
a416eb3
Fix file name
VickyStash Apr 8, 2026
ba380e0
Add patch to fix maintainVisibleContentPosition on Android inverted …
VickyStash Apr 8, 2026
ba46555
Fix jumps on transactions switching
VickyStash Apr 8, 2026
2d46054
Merge branch 'main' into VickyStash/refactor/33725-flashlist-with-inv…
VickyStash Apr 9, 2026
9633ab6
Remove not used anymore files
VickyStash Apr 9, 2026
7a3ac26
Add type comments
VickyStash Apr 9, 2026
e15428e
Update details.md
VickyStash Apr 9, 2026
b35178a
Clean up scroll-end timer
VickyStash Apr 9, 2026
d06522c
Update patch
VickyStash Apr 10, 2026
8031909
Merge branch 'main' into VickyStash/refactor/33725-flashlist-with-inv…
VickyStash Apr 10, 2026
7e41842
Merge branch 'main' into VickyStash/refactor/33725-flashlist-with-inv…
VickyStash Apr 14, 2026
47bb2f3
Hide android scroll indicator
VickyStash Apr 14, 2026
bf2a2cb
Fix keyboard overlap content
VickyStash Apr 14, 2026
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
1 change: 1 addition & 0 deletions cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -648,6 +648,7 @@
"RPID",
"RRGGBB",
"rstrip",
"recyclerview",
"RTER",
"s3uqn2oe4m85tufi6mqflbfbuajrm2i3",
"SAASPASS",
Expand Down
2 changes: 1 addition & 1 deletion jest/setup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ jest.mock('../src/components/Icon/IllustrationLoader.ts', () => ({
}));

jest.mock(
'@components/FlatList/InvertedFlatList/RenderTaskQueue',
'@components/FlatList/RenderTaskQueue',
() =>
class SyncRenderTaskQueue {
private handler: (info: unknown) => void = () => {};
Expand Down
29 changes: 29 additions & 0 deletions jest/setupAfterEnv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,35 @@ import ONYXKEYS from '@src/ONYXKEYS';

jest.useRealTimers();

// 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<string, unknown>,
);

// 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.
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 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 = <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 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 = <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(
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
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 = <T,>(
*/
// 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 = <T,>(
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();
Loading
Loading