Skip to content

Commit 8ca7477

Browse files
authored
fix: scroll to index autoscroll race (#2290)
* fix: mvcp autoscroll taking over imperative scroll * chore: add reproduction case * chore: clean fixture up a bit
1 parent f4278ba commit 8ca7477

6 files changed

Lines changed: 506 additions & 0 deletions

File tree

Lines changed: 370 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,370 @@
1+
import React, {
2+
useState,
3+
useCallback,
4+
useMemo,
5+
useRef,
6+
useEffect,
7+
} from "react";
8+
import {
9+
View,
10+
Text,
11+
Image,
12+
StyleSheet,
13+
Pressable,
14+
SafeAreaView,
15+
StatusBar,
16+
} from "react-native";
17+
import {
18+
FlashList,
19+
FlashListRef,
20+
useRecyclingState,
21+
} from "@shopify/flash-list";
22+
23+
interface ChatMessage {
24+
id: string;
25+
text: string;
26+
sender: "user" | "other";
27+
timestamp: Date;
28+
}
29+
30+
const messageTexts = [
31+
"Hey, how are you?",
32+
"Hey, how are you? Hey, how are you? Hey, how are you? Hey, how are you?",
33+
"What's up?",
34+
"What's up? What's up? What's up?",
35+
"Did you see that new movie?",
36+
"I'm working on a new project",
37+
"Let's catch up soon!",
38+
"Have you tried the new restaurant downtown?",
39+
"Have you tried the new restaurant downtown?, Have you tried the new restaurant downtown?, Have you tried the new restaurant downtown?, Have you tried the new restaurant downtown?",
40+
"Just finished reading an amazing book",
41+
"Can you send me that file?",
42+
"Are we still meeting tomorrow?",
43+
[
44+
"Are we still meeting tomorrow?",
45+
"Are we still meeting tomorrow?",
46+
"Are we still meeting tomorrow?",
47+
"Are we still meeting tomorrow?",
48+
"Are we still meeting tomorrow?",
49+
"Are we still meeting tomorrow?",
50+
"Are we still meeting tomorrow?",
51+
].join(" "),
52+
"The weather is beautiful today!",
53+
"The weather is beautiful today! The weather is beautiful today!",
54+
];
55+
56+
export function ChatUnstableItems() {
57+
const [messages] = useState<ChatMessage[]>(() =>
58+
generateInitialMessages(300)
59+
);
60+
const listRef = useRef<FlashListRef<ChatMessage>>(null);
61+
62+
// Mirror something similar to tapping a quoted message to scroll to it.
63+
// The tap schedules a state update; the actual scrollToIndex runs on a
64+
// later tick via setTimeout(0). This separates the rerender (where
65+
// applyOffsetCorrection can fire its stray scrollBy with the anchor's
66+
// stale layout snapshot) from the scrollToIndex call (which would
67+
// otherwise have already set pauseOffsetCorrection.current = true and
68+
// suppressed the stray scrollBy).
69+
const [scrollTarget, setScrollTarget] = useState<number | null>(null);
70+
71+
useEffect(() => {
72+
if (scrollTarget === null) return undefined;
73+
const id = setTimeout(() => {
74+
listRef.current?.scrollToIndex({
75+
index: scrollTarget,
76+
animated: true,
77+
viewPosition: 0.5,
78+
});
79+
setScrollTarget(null);
80+
}, 0);
81+
return () => clearTimeout(id);
82+
}, [scrollTarget]);
83+
84+
const scrollToItem25 = useCallback(() => {
85+
setScrollTarget(150);
86+
}, []);
87+
88+
const scrollToBottom = useCallback(() => {
89+
listRef.current?.scrollToEnd({ animated: true });
90+
}, []);
91+
92+
const renderItem = useCallback((info: { item: ChatMessage }) => {
93+
return <MessageBubble message={info.item} />;
94+
}, []);
95+
96+
const maintainVisibleContentPositionConfig = useMemo(
97+
() => ({
98+
animateAutoScrollToBottom: true,
99+
autoscrollToBottomThreshold: 1,
100+
startRenderingFromBottom: true,
101+
}),
102+
[]
103+
);
104+
105+
const listHeaderComponent = useMemo(
106+
() => (
107+
<View style={styles.header}>
108+
<Text style={styles.headerTitle}>Chat Example</Text>
109+
</View>
110+
),
111+
[]
112+
);
113+
114+
const keyExtractor = useCallback((item: ChatMessage) => item.id, []);
115+
116+
return (
117+
<SafeAreaView style={styles.safeArea} testID="ChatUnstableItemsScreen">
118+
<StatusBar barStyle="dark-content" />
119+
<View style={styles.container}>
120+
<View style={styles.header}>
121+
<Text style={styles.headerTitle}>Chat Example</Text>
122+
</View>
123+
124+
<View style={styles.buttonContainer}>
125+
<Pressable
126+
style={[styles.button, styles.scrollBottomButton]}
127+
onPress={scrollToBottom}
128+
>
129+
<Text style={styles.buttonText}>Scroll to Bottom</Text>
130+
</Pressable>
131+
132+
<Pressable
133+
style={[styles.button, styles.scrollButton]}
134+
onPress={scrollToItem25}
135+
>
136+
<Text style={styles.buttonText}>Scroll to 25</Text>
137+
</Pressable>
138+
</View>
139+
140+
<FlashList
141+
ref={listRef}
142+
data={messages}
143+
maintainVisibleContentPosition={maintainVisibleContentPositionConfig}
144+
ListHeaderComponent={listHeaderComponent}
145+
renderItem={renderItem}
146+
keyExtractor={keyExtractor}
147+
/>
148+
</View>
149+
</SafeAreaView>
150+
);
151+
}
152+
153+
function generateInitialMessages(count: number): ChatMessage[] {
154+
const messages: ChatMessage[] = [];
155+
const now = new Date();
156+
157+
for (let i = 0; i < count; i++) {
158+
const minutesAgo = count - i;
159+
const timestamp = new Date(now.getTime() - minutesAgo * 60000);
160+
161+
messages.push({
162+
id: `msg-${i}`,
163+
text: `${i}. ${messageTexts[i % messageTexts.length]}`,
164+
sender: i % 2 === 0 ? "user" : "other",
165+
timestamp,
166+
});
167+
}
168+
169+
return messages;
170+
}
171+
172+
// deterministic hash, to be used as seed for the randomizer
173+
function hashString(input: string): number {
174+
let hash = 0;
175+
for (let i = 0; i < input.length; i++) {
176+
hash = (hash * 31 + input.charCodeAt(i)) | 0;
177+
}
178+
return Math.abs(hash);
179+
}
180+
181+
// deterministic random based on a seed - we probably want this
182+
// in order to not mess measurements on every scroll (again emulating
183+
// actual message state which settles after some time)
184+
function mulberry32(seed: number) {
185+
let state = seed >>> 0;
186+
return () => {
187+
state = (state + 0x6d2b79f5) | 0;
188+
let t = Math.imul(state ^ (state >>> 15), 1 | state);
189+
t ^= t + Math.imul(t ^ (t >>> 7), 61 | t);
190+
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
191+
};
192+
}
193+
194+
interface MessageProfile {
195+
paddingVertical: number;
196+
paddingHorizontal: number;
197+
textRepeat: number;
198+
hasImage: boolean;
199+
imageWidth: number;
200+
imageRenderedHeight: number;
201+
imagePlaceholderHeight: number;
202+
imageSeed: number;
203+
}
204+
205+
function computeProfile(messageId: string): MessageProfile {
206+
const rand = mulberry32(hashString(messageId));
207+
const hasImage = rand() < 0.35;
208+
// The image is requested at imageRenderedHeight px tall, but we render
209+
// the <Image> at imagePlaceholderHeight until onLoad fires, at which
210+
// point we swap to the full height. That swap tries to emulate a real
211+
// measurement drift: between the moment the user settles at the bottom
212+
// (anchor snapshot taken) and the moment they tap, inflight image loads
213+
// keep growing nearby bubbles, drifting firstVisibleItemLayout.current
214+
// relative to live layout.
215+
const imageRenderedHeight = 90 + Math.floor(rand() * 130);
216+
return {
217+
paddingVertical: 8 + Math.floor(rand() * 16),
218+
paddingHorizontal: 10 + Math.floor(rand() * 8),
219+
textRepeat: 1 + Math.floor(rand() * 3),
220+
hasImage,
221+
imageWidth: 180 + Math.floor(rand() * 60),
222+
imageRenderedHeight,
223+
imagePlaceholderHeight: Math.max(40, imageRenderedHeight - 60),
224+
imageSeed: hashString(messageId),
225+
};
226+
}
227+
228+
const MessageImage = ({
229+
uri,
230+
width,
231+
placeholderHeight,
232+
finalHeight,
233+
}: {
234+
uri: string;
235+
width: number;
236+
placeholderHeight: number;
237+
finalHeight: number;
238+
}) => {
239+
const [loaded, setLoaded] = useRecyclingState(false, [uri]);
240+
return (
241+
<Image
242+
source={{ uri }}
243+
resizeMode="cover"
244+
onLoad={() => setLoaded(true)}
245+
style={{
246+
width,
247+
height: loaded ? finalHeight : placeholderHeight,
248+
borderRadius: 10,
249+
marginBottom: 6,
250+
backgroundColor: "#dddddd",
251+
}}
252+
/>
253+
);
254+
};
255+
256+
const MessageBubble = ({ message }: { message: ChatMessage }) => {
257+
const isUser = message.sender === "user";
258+
const profile = useMemo(() => computeProfile(message.id), [message.id]);
259+
const text = useMemo(() => {
260+
if (profile.textRepeat === 1) return message.text;
261+
return Array(profile.textRepeat).fill(message.text).join(" ");
262+
}, [message.text, profile.textRepeat]);
263+
264+
return (
265+
<View
266+
style={[
267+
styles.messageBubbleContainer,
268+
isUser ? styles.userMessageContainer : styles.otherMessageContainer,
269+
]}
270+
>
271+
<View
272+
style={[
273+
styles.messageBubble,
274+
isUser ? styles.userMessage : styles.otherMessage,
275+
{
276+
paddingVertical: profile.paddingVertical,
277+
paddingHorizontal: profile.paddingHorizontal,
278+
},
279+
]}
280+
>
281+
{profile.hasImage && (
282+
<MessageImage
283+
uri={`https://picsum.photos/seed/${profile.imageSeed}/${profile.imageWidth}/${profile.imageRenderedHeight}`}
284+
width={profile.imageWidth}
285+
placeholderHeight={profile.imagePlaceholderHeight}
286+
finalHeight={profile.imageRenderedHeight}
287+
/>
288+
)}
289+
<Text style={styles.messageText}>{text}</Text>
290+
</View>
291+
</View>
292+
);
293+
};
294+
295+
const styles = StyleSheet.create({
296+
safeArea: {
297+
flex: 1,
298+
backgroundColor: "#f5f5f5",
299+
},
300+
container: {
301+
flex: 1,
302+
},
303+
header: {
304+
padding: 16,
305+
backgroundColor: "#ffffff",
306+
borderBottomWidth: 1,
307+
borderBottomColor: "#e0e0e0",
308+
alignItems: "center",
309+
},
310+
headerTitle: {
311+
fontSize: 18,
312+
fontWeight: "600",
313+
},
314+
buttonContainer: {
315+
flexDirection: "row",
316+
flexWrap: "wrap",
317+
padding: 8,
318+
backgroundColor: "#ffffff",
319+
borderBottomWidth: 1,
320+
borderBottomColor: "#e0e0e0",
321+
},
322+
button: {
323+
paddingVertical: 8,
324+
paddingHorizontal: 12,
325+
borderRadius: 8,
326+
alignItems: "center",
327+
justifyContent: "center",
328+
minWidth: "45%",
329+
flexGrow: 1,
330+
margin: 4,
331+
},
332+
scrollButton: {
333+
backgroundColor: "#e2a04a",
334+
},
335+
scrollBottomButton: {
336+
backgroundColor: "#9c4ae2",
337+
},
338+
buttonText: {
339+
color: "white",
340+
fontWeight: "600",
341+
},
342+
messageBubbleContainer: {
343+
paddingHorizontal: 12,
344+
marginVertical: 4,
345+
flexDirection: "row",
346+
},
347+
userMessageContainer: {
348+
justifyContent: "flex-end",
349+
},
350+
otherMessageContainer: {
351+
justifyContent: "flex-start",
352+
},
353+
messageBubble: {
354+
maxWidth: "80%",
355+
padding: 12,
356+
borderRadius: 16,
357+
},
358+
userMessage: {
359+
backgroundColor: "#dcf8c6",
360+
borderBottomRightRadius: 1,
361+
},
362+
otherMessage: {
363+
backgroundColor: "#ffffff",
364+
borderBottomLeftRadius: 1,
365+
},
366+
messageText: {
367+
fontSize: 16,
368+
color: "#000000",
369+
},
370+
});

‎fixture/react-native/src/ExamplesScreen.tsx‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ export const ExamplesScreen = () => {
4141
title: "Chat (Inverted)",
4242
destination: "ChatInverted",
4343
},
44+
{
45+
title: "Chat (Unstable Items)",
46+
destination: "ChatUnstableItems",
47+
},
4448
{
4549
title: "Inverted Test",
4650
destination: "InvertedTest",

0 commit comments

Comments
 (0)