Skip to content

Commit f4b4a6c

Browse files
committed
fix: scroll
1 parent 0700e0d commit f4b4a6c

File tree

2 files changed

+262
-1
lines changed

2 files changed

+262
-1
lines changed

metro.config.js

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,16 @@ const config = {
1212
unstable_allowRequireContext: true
1313
},
1414
resolver: {
15-
sourceExts: process.env.RUNNING_E2E_TESTS ? ['mock.ts', ...sourceExts] : sourceExts
15+
sourceExts: process.env.RUNNING_E2E_TESTS ? ['mock.ts', ...sourceExts] : sourceExts,
16+
// Force flash-list to use source files so our patch is applied
17+
resolveRequest: (context, moduleName, platform) => {
18+
// Redirect flash-list dist imports to src
19+
if (moduleName.startsWith('@shopify/flash-list')) {
20+
const newModuleName = moduleName.replace('@shopify/flash-list', '@shopify/flash-list/src');
21+
return context.resolveRequest(context, newModuleName, platform);
22+
}
23+
return context.resolveRequest(context, moduleName, platform);
24+
}
1625
}
1726
};
1827

Lines changed: 252 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,252 @@
1+
diff --git a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx
2+
index 5447a5c..357179d 100644
3+
--- a/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx
4+
+++ b/node_modules/@shopify/flash-list/src/recyclerview/RecyclerView.tsx
5+
@@ -97,6 +97,13 @@ const RecyclerViewComponent = <T,>(
6+
const firstChildViewRef = useRef<CompatView>(null);
7+
const containerViewSizeRef = useRef<RVDimension | undefined>(undefined);
8+
const pendingChildIds = useRef<Set<string>>(new Set()).current;
9+
+ const hasInitialScrolledToBottomRef = useRef(false);
10+
+ const lastDataLengthRef = useRef(0);
11+
+
12+
+ // Track user scroll state
13+
+ const isScrolledByUserRef = useRef(false);
14+
+ const lastScrollOffsetRef = useRef(0);
15+
+ const isUserTouchingRef = useRef(false); // Track if user is actively touching/dragging
16+
17+
// Track scroll position
18+
const scrollY = useRef(new Animated.Value(0)).current;
19+
@@ -229,6 +236,17 @@ const RecyclerViewComponent = <T,>(
20+
}
21+
});
22+
23+
+ /**
24+
+ * Helper function to determine if the list is scrolled to the bottom
25+
+ */
26+
+ const isAtBottom = useCallback(
27+
+ (scrollOffset: number, contentSize: number, viewportSize: number): boolean => {
28+
+ const threshold = 50; // pixels tolerance for "at bottom" detection
29+
+ return scrollOffset + viewportSize >= contentSize - threshold;
30+
+ },
31+
+ []
32+
+ );
33+
+
34+
/**
35+
* Scroll event handler that manages scroll position, velocity, and RTL support
36+
*/
37+
@@ -251,6 +269,42 @@ const RecyclerViewComponent = <T,>(
38+
);
39+
}
40+
41+
+ // Detect user scroll state changes ONLY when user is actively touching
42+
+ if (isUserTouchingRef.current) {
43+
+ const contentSize = horizontal
44+
+ ? event.nativeEvent.contentSize.width
45+
+ : event.nativeEvent.contentSize.height;
46+
+ const viewportSize = horizontal
47+
+ ? event.nativeEvent.layoutMeasurement.width
48+
+ : event.nativeEvent.layoutMeasurement.height;
49+
+
50+
+ const atBottom = isAtBottom(scrollOffset, contentSize, viewportSize);
51+
+ const wasScrolledByUser = isScrolledByUserRef.current;
52+
+
53+
+ // Update scroll state based on position
54+
+ if (!atBottom && !wasScrolledByUser) {
55+
+ // User scrolled away from bottom
56+
+ isScrolledByUserRef.current = true;
57+
+ console.log('[FlashList] User scroll state: NOT scrolled by user → Scrolled by user');
58+
+ console.log(` - Scroll offset: ${scrollOffset.toFixed(2)}px`);
59+
+ console.log(` - Content size: ${contentSize.toFixed(2)}px`);
60+
+ console.log(` - Viewport size: ${viewportSize.toFixed(2)}px`);
61+
+ console.log(` - At bottom: false`);
62+
+ console.log(` - User touching: true`);
63+
+ } else if (atBottom && wasScrolledByUser) {
64+
+ // User scrolled back to bottom
65+
+ isScrolledByUserRef.current = false;
66+
+ console.log('[FlashList] User scroll state: Scrolled by user → NOT scrolled by user');
67+
+ console.log(` - Scroll offset: ${scrollOffset.toFixed(2)}px`);
68+
+ console.log(` - Content size: ${contentSize.toFixed(2)}px`);
69+
+ console.log(` - Viewport size: ${viewportSize.toFixed(2)}px`);
70+
+ console.log(` - At bottom: true`);
71+
+ console.log(` - User touching: true`);
72+
+ }
73+
+
74+
+ lastScrollOffsetRef.current = scrollOffset;
75+
+ }
76+
+
77+
velocityTracker.computeVelocity(
78+
scrollOffset,
79+
recyclerViewManager.getAbsoluteLastScrollOffset(),
80+
@@ -290,11 +344,38 @@ const RecyclerViewComponent = <T,>(
81+
computeFirstVisibleIndexForOffsetCorrection,
82+
horizontal,
83+
isHorizontalRTL,
84+
+ isAtBottom,
85+
recyclerViewManager,
86+
velocityTracker,
87+
]
88+
);
89+
90+
+ /**
91+
+ * Handler for when user starts dragging/touching the list
92+
+ */
93+
+ const onScrollBeginDragHandler = useCallback(
94+
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
95+
+ isUserTouchingRef.current = true;
96+
+ console.log('[FlashList] User started touching the list');
97+
+ // Call user-provided handler if exists
98+
+ recyclerViewManager.props.onScrollBeginDrag?.(event);
99+
+ },
100+
+ [recyclerViewManager]
101+
+ );
102+
+
103+
+ /**
104+
+ * Handler for when user stops dragging/touching the list
105+
+ */
106+
+ const onScrollEndDragHandler = useCallback(
107+
+ (event: NativeSyntheticEvent<NativeScrollEvent>) => {
108+
+ isUserTouchingRef.current = false;
109+
+ console.log('[FlashList] User stopped touching the list');
110+
+ // Call user-provided handler if exists
111+
+ recyclerViewManager.props.onScrollEndDrag?.(event);
112+
+ },
113+
+ [recyclerViewManager]
114+
+ );
115+
+
116+
const parentRecyclerViewContext = useRecyclerViewContext();
117+
const recyclerViewId = useId();
118+
119+
@@ -436,10 +517,15 @@ const RecyclerViewComponent = <T,>(
120+
121+
const maintainVisibleContentPositionInternal = useMemo(() => {
122+
if (shouldMaintainVisibleContentPosition) {
123+
- return {
124+
+ const config: typeof maintainVisibleContentPosition = {
125+
...maintainVisibleContentPosition,
126+
- minIndexForVisible: 0,
127+
};
128+
+ // For inverted lists starting from bottom, don't set minIndexForVisible
129+
+ // as it interferes with maintaining position of items at the bottom
130+
+ if (!maintainVisibleContentPosition?.startRenderingFromBottom) {
131+
+ config.minIndexForVisible = 0;
132+
+ }
133+
+ return config;
134+
}
135+
return undefined;
136+
}, [maintainVisibleContentPosition, shouldMaintainVisibleContentPosition]);
137+
@@ -448,6 +534,63 @@ const RecyclerViewComponent = <T,>(
138+
recyclerViewManager.getDataLength() > 0 &&
139+
(maintainVisibleContentPosition?.startRenderingFromBottom ?? false);
140+
141+
+ // Handle onContentSizeChange to ensure we scroll to bottom on first render
142+
+ // and when data changes significantly (e.g., from 1 to 50 messages)
143+
+ const handleContentSizeChange = useCallback(
144+
+ (contentWidth: number, contentHeight: number) => {
145+
+ const currentDataLength = recyclerViewManager.getDataLength();
146+
+
147+
+ // Check if this is a significant data change (e.g., 1 message -> 50 messages)
148+
+ // This handles the case where initial DB query returns 1 message, then sync loads 50
149+
+ const isSignificantDataChange =
150+
+ lastDataLengthRef.current > 0 &&
151+
+ currentDataLength > lastDataLengthRef.current &&
152+
+ (currentDataLength - lastDataLengthRef.current) >= 10;
153+
+
154+
+ // Reset the flag if we detect a significant data change
155+
+ if (isSignificantDataChange) {
156+
+ hasInitialScrolledToBottomRef.current = false;
157+
+ }
158+
+
159+
+ lastDataLengthRef.current = currentDataLength;
160+
+
161+
+ if (
162+
+ shouldRenderFromBottom &&
163+
+ !hasInitialScrolledToBottomRef.current &&
164+
+ currentDataLength > 0 &&
165+
+ contentHeight > 0
166+
+ ) {
167+
+ // Mark as attempted before scrolling to prevent multiple attempts
168+
+ hasInitialScrolledToBottomRef.current = true;
169+
+
170+
+ // Use requestAnimationFrame to ensure layout measurements are complete
171+
+ // Double RAF ensures we're past the layout phase
172+
+ requestAnimationFrame(() => {
173+
+ requestAnimationFrame(() => {
174+
+ if (handlerMethods) {
175+
+ handlerMethods.scrollToEnd({ animated: false });
176+
+
177+
+ // One more attempt after a delay to handle cases where
178+
+ // items are still measuring (variable height items)
179+
+ // Use longer delay for significant data changes
180+
+ const delay = isSignificantDataChange ? 150 : 100;
181+
+ setTimeout(() => {
182+
+ if (handlerMethods) {
183+
+ handlerMethods.scrollToEnd({ animated: false });
184+
+ }
185+
+ }, delay);
186+
+ }
187+
+ });
188+
+ });
189+
+ }
190+
+ },
191+
+ [
192+
+ shouldRenderFromBottom,
193+
+ recyclerViewManager,
194+
+ handlerMethods,
195+
+ ]
196+
+ );
197+
+
198+
// Create view for measuring bounded size
199+
const viewToMeasureBoundedSize = useMemo(() => {
200+
return (
201+
@@ -512,6 +655,9 @@ const RecyclerViewComponent = <T,>(
202+
horizontal={horizontal}
203+
ref={scrollViewRef}
204+
onScroll={animatedEvent}
205+
+ onScrollBeginDrag={onScrollBeginDragHandler}
206+
+ onScrollEndDrag={onScrollEndDragHandler}
207+
+ onContentSizeChange={handleContentSizeChange}
208+
maintainVisibleContentPosition={
209+
maintainVisibleContentPositionInternal
210+
}
211+
diff --git a/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx b/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx
212+
index 4d99406..5516f1d 100644
213+
--- a/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx
214+
+++ b/node_modules/@shopify/flash-list/src/recyclerview/hooks/useRecyclerViewController.tsx
215+
@@ -52,6 +52,7 @@ export function useRecyclerViewController<T>(
216+
const [_, setRenderId] = useState(0);
217+
const pauseOffsetCorrection = useRef(false);
218+
const initialScrollCompletedRef = useRef(false);
219+
+ const initialScrollToBottomAttemptedRef = useRef(false);
220+
const lastDataLengthRef = useRef(recyclerViewManager.getDataLength());
221+
const { setTimeout } = useUnmountAwareTimeout();
222+
223+
@@ -569,6 +570,9 @@ export function useRecyclerViewController<T>(
224+
const initialScrollIndex =
225+
recyclerViewManager.getInitialScrollIndex() ?? -1;
226+
const dataLength = data?.length ?? 0;
227+
+ const shouldRenderFromBottom =
228+
+ recyclerViewManager.props.maintainVisibleContentPosition?.startRenderingFromBottom ?? false;
229+
+
230+
if (
231+
initialScrollIndex >= 0 &&
232+
initialScrollIndex < dataLength &&
233+
@@ -598,6 +602,19 @@ export function useRecyclerViewController<T>(
234+
animated: false,
235+
skipFirstItemOffset: false,
236+
});
237+
+
238+
+ // For startRenderingFromBottom, ensure we scroll to actual bottom after layout
239+
+ // This handles variable height items where initial scroll index position might not be accurate
240+
+ if (shouldRenderFromBottom && dataLength > 0 && !initialScrollToBottomAttemptedRef.current) {
241+
+ initialScrollToBottomAttemptedRef.current = true;
242+
+ // Use multiple requestAnimationFrame calls to ensure layout measurements are complete
243+
+ requestAnimationFrame(() => {
244+
+ requestAnimationFrame(() => {
245+
+ // Scroll to end to ensure we're at the actual bottom
246+
+ handlerMethods.scrollToEnd({ animated: false });
247+
+ });
248+
+ });
249+
+ }
250+
}, 0);
251+
}
252+
}, [handlerMethods, recyclerViewManager, setTimeout]);

0 commit comments

Comments
 (0)