Repository navigation
FlatList automatically scrolls after adding new items #25239
Description
Activity
If you only need this fixed in
ios, there's an undocumented prop maintainVisibleContentPosition onScrollViewthat ought to do what you want. Unfortunately it doesn't work on android. I've been trying to solve this problem for the last two days in my own code and it's a freaking nightmare.Reacted by emision, Friedolin Förder, erum, David Predusel, Quentin Calvez, Tuan Mai, Luísa Braga, Sonny, Satwik Gupta, Trần Ngọc Phi and 23 moreReacted by meosieudang, Elias, Myeongwoo Son, Md Asif Hussain, Wisnu Agung Prasetyo, ARIJELAYNI, Kenny Cornelius, Siyamak Fazleelahi, Wendy Wang, Conor and 4 moreReacted by Nguyễn Trọng Vĩnh, Conor and Tri Lam@sgtpepper43 Thank you for participate
I need in both android and iOS.
I find workaround by keep latest y offset with onScroll and also save content height before and after adding new items with onContentSizeChange and calculate the difference of content height, and set new y offset to the latest y offset + content height difference!But its not a good way, because it has UX problem. Firstly it scrolls down and after that scrolls back to the latest position
And it's not working when I want to load my list item from realm databaseThere should be a simpler way to do this and I don't understand why there is not?!
Reacted by Friedolin Förder, dmkerfont, Quentin Calvez, suyog2370, Wisnu Agung Prasetyo, Kelvin Mitaki, David Alberto Adler, arnavr15, Ahmed Mahmoud and Anwar ShahReacted by Ioannis Kokkinidis, ivalice, Hamed Soheili, omidshafai, likai and Kevin LeeThat's the same point I'm at 😩 I was going to try and fix it, but it's taken longer to try to get the Android code building and running in our app then writing the fix itself will take... I still haven't gotten it to compile. So I'm giving up on that.
fyi, I've asked the same thing here https://stackoverflow.com/questions/56707931/prepending-data-to-a-flatlist-always-shows-the-first-child/56837491#56837491
No actual fixes so far.
Leaving that here for future reference.
Reacted by 0xd3us, Wisnu Agung Prasetyo, Sarabjit Singh and Anwar Shahhaving the same issue here on trying to prepend data to my datasource,
i found "maintainVisibleContentPosition" but that only works for IOS, Android is not supported
have you guys found any other solutions on this without manually scrollToIndex of courseI was afraid that there is no straightforward way to achieve this, but at the moment after a couple of days of trying to implement this feature, it looks like RN is not an appropriate tool to implement chat apps.
Reacted by JoyJulianGomes, Quentin Calvez, Wisnu Agung Prasetyo, gaurav0044, Kelvin Mitaki, mahadkhan121, aweffr, MJ Studio, Yoshihide-Nishimoto, arnavr15 and 4 moreReacted by Kenny Cornelius, Armin Primadi, MJ Studio, Brandon and Anwar ShahI can not believe that they are not support this feature at all!!!
I found RecyclerListView
It might solve this problem, But I didn't currently have the opportunity to test it
If someone has an opportunity, please test and give the result to the React Native community!Reacted by Friedolin Förder, Gabriel Rohden, Quentin Calvez, Harshil TM and Ahmed MahmoudThat's an issue for me as well, would be nice to have this working with SectionList.
Reacted by 0xd3us and Wendy WangReacted by khoatmdev and Mateus ReginaldoHave same problem Guys ((
maintainVisibleContentPosition not work on AndroidI created a ticket in Canny, please upvote, this has to get fixed soon https://react-native.canny.io/feature-requests/p/allow-prepending-new-items-to-flatlists-without-side-effects
Reacted by Friedolin Förder, Freddy Harris, Alexey Vertinsky, Jeremy Dunn, Michael Lustig, Marc Neumann, Libra, meosieudang, Niccolò Belli, David Predusel and 25 more@sahrens
Could you please triage this? This is a regression/bug, right? If so, any advice how to fix it? Thanks!Reacted by Alexey Vertinsky, Quentin Calvez, Kyler Jensen and Conori check this same way not work
I can not believe that they are not support this future at all!!!
I found RecyclerListView
It might solve this problem, But I didn't currently have the opportunity to test it
If someone has an opportunity, please test and give the result to the React Native community!I can not believe that they are not support this future at all!!!
I found RecyclerListView
It might solve this problem, But I didn't currently have the opportunity to test it
If someone has an opportunity, please test and give the result to the React Native community!@naqvitalha can you change you component to mak it work as we need?
161 remaining items
Load more actionsimport React, { useCallback, useEffect, useRef, useState } from 'react';
import {
View,
RefreshControl,
ActivityIndicator,
TouchableOpacity,
ViewToken,
Dimensions,
Alert,
} from 'react-native';
import { createStyleSheet, useStyles } from 'react-native-unistyles';
import { MessageItemD, MessageType } from '../../../../types/conversations/message';
import MessageBox from './MessageBox';
import DateSeparator from './components/DateSeparator';
import { formatMessageDate } from './utils/dateUtils';
import { deleteMessageApi } from '../../../../network/api/conversations';
import useProjectStore from '../../../../store/projectStore';
import { useAuth } from '../../../../context/AuthContext';
import useMessagesStore from '../../../../store/messagesStore';
import Animated, {
useAnimatedStyle,
useSharedValue,
withSequence,
withSpring,
withTiming,
} from 'react-native-reanimated';
import { isToday } from 'date-fns';
import { Typography } from '../../../../components/v2';
import FeedbackItem from './components/message-types/FeedbackItem';
import { ArrowDown } from 'lucide-react-native';
import ParticipantAdded from './components/(message_types)/ParticipantAdded';
import Resolved from './components/(message_types)/Resolved';
import IntentDetected from './components/(message_types)/IntentDetected';
import SystemFunction from './components/(message_types)/SystemFunction';
import ApiCall from './components/(message_types)/ApiCall';
import AiAction from './components/(message_types)/AiAction';
import CustomCode from './components/(message_types)/CustomCode';
import KnowledgeBase from './components/(message_types)/KnowledgeBase';
import useChatStore from '../../../../store/chatStore';
import { FlashList, FlashListRef } from '@shopify/flash-list';interface Props {
messages: MessageItemD[];
onLoadMore: () => void;
sessionUid: string;
onMessageLongPress?: (message: MessageItemD) => void;
}export default function MessageList({
messages,
onLoadMore,
sessionUid,
onMessageLongPress,
}: Props) {
const { styles, theme } = useStyles(stylesheet);
const { token } = useAuth();
const { activeProjectUId } = useProjectStore();
const { deleteMessage, loading, unSeenMessagesCount, updateUnSeenMessagesCount } = useMessagesStore();
const { getLiveTranslation } = useChatStore();
const transltationState = getLiveTranslation(sessionUid)const [currentDate, setCurrentDate] = useState<string | null>(null);
const dateIndicatorOpacity = useSharedValue(0);
const textOpacity = useSharedValue(1);
const flashListRef = useRef<FlashListRef>(null);
const scrollToBottomButtonOpacity = useSharedValue(0);
const [isAtBottom, setIsAtBottom] = useState(true); // Start at bottom since newest messages are at the top// Scroll to bottom function
const scrollToBottom = useCallback(() => {
if (flashListRef.current && messages.length > 0) {
try {
// Scroll to the first item (index 0) which is the latest message in reversed array
flashListRef.current.scrollToIndex({
index: messages.length - 1,
animated: true,
viewPosition: 1 // Align to top of the list
});
// Reset unseen messages count when scrolling to bottom
updateUnSeenMessagesCount(0);
setIsAtBottom(true);
} catch (error) {
console.warn('Failed to scroll to bottom:', error);
// Fallback to scrollToEnd if scrollToIndex fails
flashListRef.current.scrollToEnd({ animated: true });
}
}
}, [messages.length, updateUnSeenMessagesCount]);// Handle scroll events to detect when user is at bottom
const handleScroll = useCallback((event: any) => {
const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent;// For non-inverted FlashList, we need to check if we're at the actual bottom const isAtBottomNow = contentOffset.y + layoutMeasurement.height >= contentSize.height - 10; // 10px threshold if (isAtBottomNow && !isAtBottom) { // User has reached the bottom, reset unseen messages count setIsAtBottom(true); updateUnSeenMessagesCount(0); } else if (!isAtBottomNow && isAtBottom) { // User has scrolled away from bottom setIsAtBottom(false); }}, [isAtBottom, updateUnSeenMessagesCount]);
// Update button visibility based on unseen messages count and scroll position
useEffect(() => {
if (unSeenMessagesCount >= 1 && !isAtBottom) {
scrollToBottomButtonOpacity.value = withSpring(1);
} else {
scrollToBottomButtonOpacity.value = withSpring(0);
}
}, [unSeenMessagesCount, isAtBottom, scrollToBottomButtonOpacity]);// Reset unseen messages count when user is at bottom and new messages arrive
useEffect(() => {
if (isAtBottom && unSeenMessagesCount > 0) {
updateUnSeenMessagesCount(0);
}
}, [isAtBottom, unSeenMessagesCount, updateUnSeenMessagesCount]);const handleDeleteMessage = useCallback(
async (messageId: string) => {
console.log('messageId', messageId, activeProjectUId, sessionUid);
if (!token || !activeProjectUId || !sessionUid) return;try { await deleteMessageApi({ token, project_uid: activeProjectUId, session_uid: sessionUid, message_id: messageId, }); // Remove the message from the store deleteMessage({ sessionUid, messageId: Number(messageId), }); } catch (error) { console.error('Failed to delete message:', error); } }, [token, activeProjectUId, sessionUid, deleteMessage],);
// Handle viewable items changed
const onViewableItemsChanged = useCallback(
({ viewableItems }: { viewableItems: ViewToken[] }) => {
if (viewableItems.length > 0) {
const topMessage = viewableItems[viewableItems.length - 1].item as MessageItemD;
const messageDate = new Date(topMessage.createdAt || Date.now());
const dateStr = formatMessageDate(messageDate);// Only show date indicator if it's not today if (!isToday(messageDate)) { if (dateStr !== currentDate) { // Fade out current text, update date, fade in new text textOpacity.value = withSequence( withTiming(0, { duration: 150 }), withTiming(1, { duration: 150 }), ); } setCurrentDate(dateStr); dateIndicatorOpacity.value = withSpring(1); } else { dateIndicatorOpacity.value = withSpring(0); setCurrentDate(null); } } else { dateIndicatorOpacity.value = withSpring(0); } }, [dateIndicatorOpacity, textOpacity, currentDate],);
const viewabilityConfig = useRef({
itemVisiblePercentThreshold: 50,
minimumViewTime: 100,
}).current;const MESSAGE_TYPE_COMPONENTS: Partial<Record<MessageType, React.ComponentType<{ message: MessageItemD }>>> = {
[MessageType.intent_detected]: IntentDetected,
[MessageType.added_participants]: ParticipantAdded,
[MessageType.mark_resolved]: Resolved,// Tool actions [MessageType.system_function]: SystemFunction, [MessageType.api_call]: ApiCall, [MessageType.ai_action]: AiAction, [MessageType.custom_code]: CustomCode, [MessageType.knowledgebase]: KnowledgeBase,} as const;
// Create reversed messages array for consistent indexing
const reversedMessages = [...messages].reverse();const renderMessage = useCallback(
({ item: message, index }: { item: MessageItemD; index: number }) => {
// Show avatar for last message in a group OR when next message has different sender
const showAvatar =
index === reversedMessages.length - 1 || (reversedMessages[index + 1]?.send_by !== message.send_by);// Show date for first message OR when current message date differs from previous const showDate = index === 0 || formatMessageDate(new Date(message.createdAt || new Date())) !== formatMessageDate( new Date(reversedMessages[index - 1]?.createdAt || new Date()), ); if (message.content_type === 'feedback') { return ( <FeedbackItem message={message} /> ) } if (message.type && message.type in MESSAGE_TYPE_COMPONENTS) { const MessageComponent = MESSAGE_TYPE_COMPONENTS[message.type as MessageType]; if (MessageComponent) { return <MessageComponent message={message} /> } } return ( <View style={{ flexDirection: showDate ? 'column' : 'column-reverse' }}> {showDate && ( <DateSeparator date={formatMessageDate( new Date(message.createdAt || new Date()), )} /> )} <TouchableOpacity onLongPress={() => onMessageLongPress?.(message)} activeOpacity={0.8}> <MessageBox message={message} showAvatar={showAvatar} deleteMessage={handleDeleteMessage} sessionUid={sessionUid} /> </TouchableOpacity> </View> ); }, [reversedMessages, handleDeleteMessage, onMessageLongPress, sessionUid],);
const getUniqueKey = useCallback((item: MessageItemD, index: number) => {
let baseKey = '';// If we have a localId, use it with a prefix if (item.localId !== undefined && item.localId !== null) { baseKey = `local_${item.localId}`; } // If we have message_id, use it else if (item.message_id !== undefined && item.message_id !== null) { baseKey = `msg_${item.message_id}`; } // If we have id, use it else if (item.id !== undefined && item.id !== null) { baseKey = `id_${item.id}`; } // Fallback to timestamp else { const timestamp = item.createdAt ? new Date(item.createdAt).getTime() : Date.now(); baseKey = `time_${timestamp}`; } // Always append the index to ensure uniqueness during fast scrolling return `${baseKey}_idx_${index}`;}, []);
// Animated style for the floating date indicator
const dateIndicatorStyle = useAnimatedStyle(() => ({
opacity: dateIndicatorOpacity.value,
transform: [{ scale: dateIndicatorOpacity.value }],
}));const dateTextStyle = useAnimatedStyle(() => ({
opacity: textOpacity.value,
}));// Animated style for the scroll to bottom button
const scrollToBottomButtonStyle = useAnimatedStyle(() => ({
opacity: scrollToBottomButtonOpacity.value,
transform: [{ scale: scrollToBottomButtonOpacity.value }],
}));return (
{!transltationState?.show && <Animated.View style={[styles.dateIndicator, dateIndicatorStyle]}>
<Animated.View style={dateTextStyle}>
{currentDate}
</Animated.View>
</Animated.View>
}{/* Scroll to bottom button */} <Animated.View style={[styles.scrollToBottomButton, scrollToBottomButtonStyle]}> <TouchableOpacity style={styles.scrollToBottomButtonContent} onPress={scrollToBottom} activeOpacity={0.8} > <View style={styles.scrollToBottomButtonInner}> <ArrowDown size={16} color={theme.colors.surface} /> <View style={styles.messageCountBadge}> <Typography variant="captionMedium" style={styles.messageCountText}> {unSeenMessagesCount} </Typography> </View> </View> </TouchableOpacity> </Animated.View> <FlashList ref={flashListRef} style={styles.list} keyboardShouldPersistTaps="handled" data={reversedMessages} renderItem={renderMessage} keyExtractor={getUniqueKey} contentContainerStyle={styles.contentContainer} removeClippedSubviews={false} onStartReached={onLoadMore} onStartReachedThreshold={0.5} initialScrollIndex={reversedMessages.length - 1} initialScrollIndexParams={{ viewOffset: 10000, }} onScroll={handleScroll} scrollEventThrottle={16} ListEmptyComponent={() => ( <View style={styles.emptyContainer}> {loading ? <ActivityIndicator size="small" color={theme.colors.primary} /> : <Typography variant='body1' color='textSecondary'>No messages found</Typography>} </View> )} onViewableItemsChanged={onViewableItemsChanged} viewabilityConfig={viewabilityConfig} maxItemsInRecyclePool={50} maintainVisibleContentPosition={{ autoscrollToTopThreshold: 0, autoscrollToBottomThreshold: 0, }} /> </View>);
}const stylesheet = createStyleSheet(theme => ({
container: {
flex: 1,
width: '100%',
},
list: {
flex: 1,
},
contentContainer: {
paddingVertical: theme.spacing.md,
flexGrow: 1,
},
loadingContainer: {
paddingVertical: theme.spacing.md,
alignItems: 'center',
},
dateIndicator: {
position: 'absolute',
top: 16,
alignSelf: 'center',
backgroundColor: theme.colors.surface,
paddingHorizontal: 12,
paddingVertical: 6,
borderRadius: 16,
zIndex: 100,
shadowColor: theme.colors.textSecondary,
shadowOffset: {
width: 0,
height: 2,
},
shadowOpacity: 0.25,
shadowRadius: 3.84,
elevation: 5,
borderWidth: 1,
borderColor: theme.colors.border,
},
dateIndicatorText: {
color: theme.colors.textPrimary,
fontSize: 13,
fontWeight: '600',
},
scrollToBottomButton: {
position: 'absolute',
bottom: 20,
right: 20,
zIndex: 100,
},
scrollToBottomButtonContent: {
// backgroundColor: theme.colors.primary,
borderRadius: theme.borderRadius.full,
shadowColor: theme.colors.textSecondary,
shadowOffset: {
width: 0,
height: 4,
},
shadowOpacity: 0.3,
shadowRadius: 5,
elevation: 8,
},
scrollToBottomButtonInner: {
flexDirection: 'row',
alignItems: 'center',
backgroundColor:theme.colors.primary,
borderRadius: theme.borderRadius.full,
paddingHorizontal: 12,
paddingVertical: 8,
// gap: 8,
},
messageCountBadge: {
position: 'absolute',
right: -4,
top: -4,
backgroundColor: theme.colors.surface,
borderRadius: theme.borderRadius.full,
minWidth: 20,
height: 20,
paddingHorizontal: 6,
justifyContent: 'center',
alignItems: 'center',
},
messageCountText: {
color: theme.colors.primary,
fontSize: 11,
fontWeight: '700',
},
emptyContainer: {
flex: 1,
justifyContent: 'center',
alignItems: 'center',
minHeight:'100%',
}
}));Can anyone help me improve the scrolling functionality as i list is automatically scrolling to top when loadMore function executes and append in the array.
Looking for a smooth scrolling experience similar to any Chat AppThis issue is stale because it has been open 180 days with no activity. Remove stale label or comment or this will be closed in 7 days.
- addedStaleThere has been a lack of activity on this issue and it may be closed soon.There has been a lack of activity on this issue and it may be closed soon.
on Mar 31, 2026 2026 and still not solved ?
Reacted by Faizal Shap- removedStaleThere has been a lack of activity on this issue and it may be closed soon.There has been a lack of activity on this issue and it may be closed soon.
on Apr 1, 2026 Use FlashList instead
2026 still not solved, crazy..
Root Cause Analysis: FlatList Auto-Scroll on Item Prepend (Issue #25239)
The Issue
FlatList automatically scrolls when new items are prepended (added to the front/middle) of the data list. The scroll position shifts because FlatList tries to maintain the current viewport Y offset, but when items are added before the visible items, that Y offset no longer corresponds to the same content.
The comment referenced (by @vovakovalchukk, May 2026): Notes this 7-year-old issue remains unfixed.
Root Cause
The root cause is a fundamental algorithmic limitation in how
maintainVisibleContentPositionworks, combined with FlatList's view recycling. The algorithm tracks a single view's frame position and applies a delta correction on layout changes — but this breaks down in several ways with FlatList's virtualized rendering.maintainVisibleContentPositionAlgorithm and Why It FailsThe algorithm (on both iOS and Android) works like this:
- After scrolling, find the first visible view and store its frame (
prevFirstVisibleFrame) - On layout change, compute
deltaY = newFrame.top - prevFirstVisibleFrame.top - Adjust scroll position by
scrollY + deltaY
This algorithm fails for prepend scenarios because:
-
View recycling (FlatList's windowing): FlatList only keeps ~7-15 items mounted at a time (controlled by
windowSize). When you prepend more items than the visible window, the previously-stored view reference becomes stale — the view has been recycled for a different data item. The WeakReference may even point to a view that's no longer in the hierarchy. (@jjd314, Oct 2020: "if you add more than (windowSize-1)/2 items to the top of your data, it loses the scroll position (because the previously visible item will have been reclaimed)") -
Single-view tracking: The algorithm only tracks ONE view. When items are prepended, the tracked view may shift off-screen, and no other visible views are monitored. The algorithm has no fallback.
-
Variable-height items: The delta calculation assumes items maintain consistent vertical positions. With variable-height items (common in chat feeds), the delta computed from one layout change doesn't accurately predict the shift from the next.
-
Timing/debounce issues: Android implementation uses a 100ms debounce on scroll changes (@maxoumime, Feb 2021). This can miss rapid layout changes or introduce visible lag. (@Strate, Sep 2020: "I would prefer @stackia's implementation, because it matches iOS implementation, and do not use debounce, which leads to possible lags in implementation")
-
Inertia scroll interference (Android): When
OverScrolleris animating (user flung the list), callingsetScrollY()doesn't update the internalOverScrollerstate. The scroll position appears to jump back when inertia resumes. (@Strate, Sep 2020: "on android just callingsetScrollY()does not affect scroll animation - if list scrolling by inertia via OverScroller class, altering scrollY on ScrollView does not affects internal values inandroid.widget.OverScroller- so you need to stop it and re-launch again.") -
Inverted lists: The algorithm doesn't account for inverted scroll direction (where new items appear at the "bottom" visually but are prepended to the data array).
Evidence from the issue comments:
- @ingeniousgenius (Nov 2019): "The maintainVisibleContentPosition prop seems to do nothing for me." (6 reactions, 5 "+1")
- @fungchun1121 (Feb 2020): "There still isn't a feasible solution for both iOS and Android as of now." (30 reactions, 30 "+1")
- @fungchun1121 (Mar 2020): "Sorry can you show me the solution which works on iOS?" — challenging the claim that iOS works
- @jjd314 (Oct 2020): FlatList view recycling causes scroll loss when prepending more than (windowSize-1)/2 items
Why It Happens (Technical Details)
- FlatList is built on ScrollView — it delegates scroll management to the native ScrollView component
- When data changes, the native view re-renders and content size changes
- ScrollView's default behavior is to keep the current scroll offset (Y position) visible
- When items are prepended, the items that were at offset Y are now pushed down, so ScrollView scrolls to keep the new content at that same offset — which appears as an unwanted jump
- The
maintainVisibleContentPositionprop is designed to solve this by anchoring to a specific index/first visible item rather than a raw pixel offset — but it only works on iOS
Why Workarounds Fail
Users tried several approaches, all with problems:
- Manual offset tracking (
onScroll+onContentSizeChange): Causes a visible flash — scrolls down then back maintainVisibleContentPosition: Doesn't work reliably on iOS (view recycling, single-view tracking); didn't exist on Android until PR Support forScrollView.maintainVisibleContentPositionon Android #29466 (which had unresolved OverScroller issues)- RecyclerListView (Flipkart): Doesn't solve the problem either
- Inverted FlatList + message queuing: Partial workaround for chat apps, but still breaks when prepending multiple items while scrolled up
- Custom Android implementation (@KyoungHwanLee, May 2020): Implemented a basic version for Android 0.61.5, but
minIndexForVisibledidn't work; never submitted as a PR - Buffer render timer (@JamesMahy, May 2020): Delayed batching of prepended items — "far from ideal"
- Custom Animated.Views carousel (@SudoPlz): Built a carousel from scratch using 5 Animated.Views with interpolation — never open-sourced
- @stream-io/flat-list-mvcp (Dec 2020): npm package based on @stackia's native module — works but requires native module setup (not possible in Expo managed workflow)
The PR That Almost Fixed It (#29466)
A PR titled "Support for
ScrollView.maintainVisibleContentPositionon Android" was opened in July 2020 by @maxoumime. It was:- Complete implementation covering
ReactScrollView,ReactHorizontalScrollView, and their managers - Heavily upvoted (53 "+1" reactions)
- Assigned to React Native team member (@yungsters)
- Marked "Needs: React Native Team Attention"
It was never merged. The PR was closed in June 2023 without resolution.
Known issues raised during review (never addressed):
- Debounce causes lags — @Strate (Sep 2020): "I would prefer @stackia's implementation, because it matches iOS implementation, and do not use debounce, which leads to possible lags in implementation"
- OverScroller state desync — @Strate (Sep 2020): "on android just calling
setScrollY()does not affect scroll animation - if list scrolling by inertia via OverScroller class, altering scrollY on ScrollView does not affects internal values inandroid.widget.OverScroller- so you need to stop it and re-launch again." - FlatList window recycling — @jjd314 (Oct 2020): "if you add more than (windowSize-1)/2 items to the top of your data, it loses the scroll position (because the previously visible item will have been reclaimed)"
- Alternative implementation preferred — @stackia provided a UIManager-based implementation that avoids debounce (28 "+1" reactions), but it was never adopted
The PR author (@maxoumime, Feb 2021) acknowledged the OverScroller issue but said "I don't think this PR should aim at fixing this though." The React Native team never addressed any of these concerns and the PR was eventually closed as stale.
Community workarounds that emerged:
- @stackia's native module (28 "+1" reactions) — used by many as a drop-in replacement
- @vishalnarkede published
@stream-io/flat-list-mvcpnpm package (Dec 2020) based on @stackia's implementation - @KyoungHwanLee published a custom fork for RN 0.61.5 (May 2020)
Summary
Aspect Detail Root cause maintainVisibleContentPositionalgorithm uses single-view frame tracking that breaks with FlatList's view recycling; Android implementation (PR #29466) had unresolved OverScroller state issuesCategory Platform parity bug / missing native feature Severity Blocks chat app development on React Native Status Open since June 2019 (7+ years); PR #29466 closed unmerged in 2023 Why unfixed Core algorithm has fundamental limitations (view recycling, single-view tracking); PR #29466 had unresolved OverScroller issues; React Native team shifted focus to New Architecture (Fabric); Expo managed workflow compatibility concerns Workaround None fully satisfactory. Best known: inverted FlatList with "new messages" queue + threshold-based prepending Reacted by Imran Mnts- After scrolling, find the first visible view and store its frame (
Any update??
In my patch branch, I implemented the fix to recompute first-visible views after every layout change, which addressed the stale view problem. That seemed to fix positioning as long as prepends are smaller than
windowSize. I then added a bunch of additional test coverage to verify each scenario.At that point it was easy to reproduce the issue of adding more items than
windowSize, which pushes the anchor item out of view, recycles the item, and corrupts the positioning again. I'm still trying to resolve that, and unfortunately it might require a new strategy I haven't discovered yet.Latent bugs like divide by zero or missed clears affecting layout are slowing progress because I discover them as I add instrumentation:
if (this._measuredCellsCount > 0) { // <-- missing this._averageCellLength = this._measuredCellsLength / this._measuredCellsCount; } // ... if (orientation.horizontal !== this._orientation.horizontal) { this._cellMetrics.clear(); // <-- missing this._averageCellLength = 0; // ... }
Turns out that fix was more impactful than I expected. Between my first patch, some anchor frame fixes, and this one, it appears to be working now: vertical, horizontal, inverted, and prepend > windowSize. I'll clean up the patch and tests and get it submitted.
Quick update-- I expanded test coverage and I'm seeing differences between iOS and Android. There appear to be some functional edge cases still I haven't bottomed out.
Working on a few doc updates before I submit the PR, but code changes are here if anyone wants to review or test in the interim.
main...aarononeal:fork-facebook-react-native:fix/mvcp-corrections-and-testsReacted by Fahmi Ben Khalifa and Imran MntsPR submitted. Please test to see if it fixes things for you. I added tests and docs for this area. If I didn't cover your specific scenario, send a repro or test and I'll take a look when I can.
Reacted by Fahmi Ben Khalifa and Imran Mnts- added a commit that references this issue
on Jul 28, 2026
FlatList automatically scrolls after change data and adds new data in the front or middle of the data list.
It only works correctly when adds new item to the end of list
I checked scroll offset and understand that FlatList scrolls to keep the latest content Y offset
I mean when the content size changes, the latest Y offset now is not where the user was before! but FlatList scrolls to it
React Native version:
react-native: 0.59.4
System:
OS: Windows 7
CPU: (2) x64 Intel(R) Pentium(R) CPU G4400 @ 3.30GHz
Memory: 310.09 MB / 3.87 GB
Binaries:
Yarn: 1.15.2 - C:\Program Files (x86)\Yarn\bin\yarn.CMD
npm: 6.9.0 - C:\Program Files\nodejs\npm.CMD
IDEs:
Android Studio: Version 3.2.0.0 AI-181.5540.7.32.5056338
Describe what you expected to happen:
It shouldn't scroll to new position when I add new items to list, and should keep latest position where user was
Example code:
<FlatList
inverted
style={{flex: 1}}
data={this.data}
keyExtractor={(item, index) => item.id}
renderItem={this.renderItem}
ref={ref => this.flatList = ref}
/>
Code that adds new items to list:
this.data = [ ...newItems, ...this.data ];