Skip to content

Commit df7b6ae

Browse files
Tom910facebook-github-bot
authored andcommitted
fix item disappearing with scroll in VirtualizedList (#47965)
Summary: Pull Request resolved: #47965 Changelog: [General] [Changed] - fix item disappearing with scroll in VirtualizedList It was caused because the function `computeWindowedRenderLimits` collapsed the current window size to just 1 element. So, users start scroll current window increase from {left:0, right:5 } -> {left:0, right:6 } and after some edge cases the window collapsed to `{left:6, right:6 }` which cause to remove all elements and recreate them later. As a result users have a lot of lags and blank pages. The diff fixes the collapsing window size to 1 element. Also fix other decreasing `left` position even if windowSize more than current amount of elements. Reviewed By: NickGerleman Differential Revision: D66334188 fbshipit-source-id: 2162d00d03d64ab6325c0492d87449051e68a4e9
1 parent 016f445 commit df7b6ae

File tree

4 files changed

+189
-4
lines changed

4 files changed

+189
-4
lines changed

packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -511,6 +511,15 @@ const definitions: FeatureFlagDefinitions = {
511511
purpose: 'release',
512512
},
513513
},
514+
fixVirtualizeListCollapseWindowSize: {
515+
defaultValue: false,
516+
metadata: {
517+
dateAdded: '2024-11-22',
518+
description:
519+
'Fixing an edge case where the current window size is not properly calculated with fast scrolling. Window size collapsed to 1 element even if windowSize more than the current amount of elements',
520+
purpose: 'experimentation',
521+
},
522+
},
514523
isLayoutAnimationEnabled: {
515524
defaultValue: true,
516525
metadata: {

packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<42e14ee5e2201b2fcbf9fe550e5165a6>>
7+
* @generated SignedSource<<38ad29621eeb29a9f82735dc187c13d4>>
88
* @flow strict
99
*/
1010

@@ -36,6 +36,7 @@ export type ReactNativeFeatureFlagsJsOnly = {
3636
enableAnimatedAllowlist: Getter<boolean>,
3737
enableAnimatedClearImmediateFix: Getter<boolean>,
3838
enableAnimatedPropsMemo: Getter<boolean>,
39+
fixVirtualizeListCollapseWindowSize: Getter<boolean>,
3940
isLayoutAnimationEnabled: Getter<boolean>,
4041
shouldSkipStateUpdatesForLoopingAnimations: Getter<boolean>,
4142
shouldUseAnimatedObjectForTransform: Getter<boolean>,
@@ -143,6 +144,11 @@ export const enableAnimatedClearImmediateFix: Getter<boolean> = createJavaScript
143144
*/
144145
export const enableAnimatedPropsMemo: Getter<boolean> = createJavaScriptFlagGetter('enableAnimatedPropsMemo', true);
145146

147+
/**
148+
* Fixing an edge case where the current window size is not properly calculated with fast scrolling. Window size collapsed to 1 element even if windowSize more than the current amount of elements
149+
*/
150+
export const fixVirtualizeListCollapseWindowSize: Getter<boolean> = createJavaScriptFlagGetter('fixVirtualizeListCollapseWindowSize', false);
151+
146152
/**
147153
* Function used to enable / disabled Layout Animations in React Native.
148154
*/

packages/virtualized-lists/Lists/VirtualizeUtils.js

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ import type ListMetricsAggregator, {
1414
CellMetricProps,
1515
} from './ListMetricsAggregator';
1616

17+
import * as ReactNativeFeatureFlags from 'react-native/src/private/featureflags/ReactNativeFeatureFlags';
18+
1719
/**
1820
* Used to find the indices of the frames that overlap the given offsets. Useful for finding the
1921
* items that bound different windows of content, such as the visible area or the buffered overscan
@@ -176,10 +178,20 @@ export function computeWindowedRenderLimits(
176178
break;
177179
}
178180
const maxNewCells = newCellCount >= maxToRenderPerBatch;
179-
const firstWillAddMore = first <= prev.first || first > prev.last;
181+
182+
let firstWillAddMore;
183+
let lastWillAddMore;
184+
185+
if (ReactNativeFeatureFlags.fixVirtualizeListCollapseWindowSize()) {
186+
firstWillAddMore = first <= prev.first;
187+
lastWillAddMore = last >= prev.last;
188+
} else {
189+
firstWillAddMore = first <= prev.first || first > prev.last;
190+
lastWillAddMore = last >= prev.last || last < prev.first;
191+
}
192+
180193
const firstShouldIncrement =
181194
first > overscanFirst && (!maxNewCells || !firstWillAddMore);
182-
const lastWillAddMore = last >= prev.last || last < prev.first;
183195
const lastShouldIncrement =
184196
last < overscanLast && (!maxNewCells || !lastWillAddMore);
185197
if (maxNewCells && !firstShouldIncrement && !lastShouldIncrement) {

packages/virtualized-lists/Lists/__tests__/VirtualizeUtils-test.js

Lines changed: 159 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,13 @@
1010

1111
'use strict';
1212

13-
import {elementsThatOverlapOffsets, newRangeCount} from '../VirtualizeUtils';
13+
import ListMetricsAggregator from '../ListMetricsAggregator';
14+
import {
15+
computeWindowedRenderLimits,
16+
elementsThatOverlapOffsets,
17+
newRangeCount,
18+
} from '../VirtualizeUtils';
19+
import * as ReactNativeFeatureFlags from 'react-native/src/private/featureflags/ReactNativeFeatureFlags';
1420

1521
describe('newRangeCount', function () {
1622
it('handles subset', function () {
@@ -116,3 +122,155 @@ function fakeProps(length) {
116122
getItemCount: () => length,
117123
};
118124
}
125+
126+
describe('computeWindowedRenderLimits', function () {
127+
const defaultProps = {
128+
getItemCount: () => 10,
129+
getItem: (_, index) => ({key: `test_${index}`}),
130+
getItemLayout: (_, index) => {
131+
return {index, length: 100, offset: index * 100};
132+
},
133+
};
134+
135+
const defaultScrollMetrics = {
136+
dt: 16,
137+
dOffset: 0,
138+
offset: 0,
139+
timestamp: 0,
140+
velocity: 0,
141+
visibleLength: 500,
142+
zoomScale: 1,
143+
};
144+
145+
it('renders all items when list is small', function () {
146+
const props = {
147+
...defaultProps,
148+
getItemCount: () => 3,
149+
};
150+
const result = computeWindowedRenderLimits(
151+
props,
152+
5,
153+
10,
154+
{first: 0, last: 2},
155+
new ListMetricsAggregator(),
156+
defaultScrollMetrics,
157+
);
158+
expect(result).toEqual({first: 0, last: 2});
159+
});
160+
161+
it('handles overflow cases when window size suddenly collapses', function () {
162+
ReactNativeFeatureFlags.override({
163+
fixVirtualizeListCollapseWindowSize: () => true,
164+
});
165+
166+
const listMetricsAggregator = new ListMetricsAggregator();
167+
listMetricsAggregator._contentLength = 2713.60009765625;
168+
169+
const offsets = [
170+
{index: 0, length: 275, offset: 0},
171+
{index: 1, length: 352, offset: 275},
172+
{index: 2, length: 326, offset: 627},
173+
{index: 3, length: 352, offset: 953},
174+
{index: 4, length: 293, offset: 1305},
175+
{index: 5, length: 293, offset: 1598},
176+
{index: 6, length: 293, offset: 1891},
177+
{index: 7, length: 293, offset: 2184},
178+
];
179+
180+
expect(
181+
computeWindowedRenderLimits(
182+
{
183+
...defaultProps,
184+
getItemCount: () => 8,
185+
getItemLayout: (_, index) => {
186+
return offsets[index];
187+
},
188+
},
189+
1,
190+
31,
191+
{first: 0, last: 5},
192+
listMetricsAggregator,
193+
{
194+
dt: 949,
195+
dOffset: 879.2000732421875,
196+
offset: 2073.60009765625,
197+
timestamp: 1732180589708,
198+
velocity: 0.9264489707504611,
199+
visibleLength: 640,
200+
},
201+
),
202+
).toEqual({first: 0, last: 6});
203+
});
204+
205+
it('handles reaching the end of the list', function () {
206+
const listMetricsAggregator = new ListMetricsAggregator();
207+
listMetricsAggregator._contentLength = 1000;
208+
209+
const offsets = Array.from({length: 10}, (_, index) => ({
210+
index,
211+
length: 100,
212+
offset: index * 100,
213+
}));
214+
215+
expect(
216+
computeWindowedRenderLimits(
217+
{
218+
...defaultProps,
219+
getItemLayout: (_, index) => offsets[index],
220+
},
221+
2,
222+
5,
223+
{first: 5, last: 9},
224+
listMetricsAggregator,
225+
{
226+
dt: 100,
227+
dOffset: 100,
228+
offset: 900,
229+
timestamp: 1000,
230+
velocity: 1,
231+
visibleLength: 300,
232+
},
233+
),
234+
).toEqual({first: 3, last: 9});
235+
});
236+
237+
it('respects maxToRenderPerBatch when adding new cells', function () {
238+
const scrollMetrics = {
239+
...defaultScrollMetrics,
240+
offset: 0,
241+
dOffset: 0,
242+
velocity: 0,
243+
};
244+
const prev = {first: 0, last: 2};
245+
const result = computeWindowedRenderLimits(
246+
defaultProps,
247+
2, // maxToRenderPerBatch
248+
5, // windowSize
249+
prev,
250+
new ListMetricsAggregator(),
251+
scrollMetrics,
252+
);
253+
expect(result).toEqual({first: 0, last: 4});
254+
});
255+
256+
it('handles case where overscanFirst and overscanLast encompass entire list', function () {
257+
const props = {
258+
...defaultProps,
259+
getItemCount: () => 5,
260+
};
261+
const scrollMetrics = {
262+
...defaultScrollMetrics,
263+
offset: 0,
264+
visibleLength: 1000,
265+
};
266+
const result = computeWindowedRenderLimits(
267+
props,
268+
5,
269+
10, // windowSize large enough to cover entire list
270+
{first: 0, last: 4},
271+
new ListMetricsAggregator(),
272+
scrollMetrics,
273+
);
274+
expect(result).toEqual({first: 0, last: 4});
275+
});
276+
});

0 commit comments

Comments
 (0)