Skip to content

Commit 557172a

Browse files
mozziusclaude
andcommitted
Keep VirtualizedList's on-screen cells when estimates above them overlap them
computeWindowedRenderLimits finds the visible cells by binary-searching getCellMetricsApprox, which places an unmeasured cell at the current average cell length times its index. A spacer keeps the size it was rendered with, so once the average changes, the estimates for the cells in a spacer can overlap the rendered cells after it. After a large prepend at the top under maintainVisibleContentPosition, the prepended cells rendered above the spacer change the average before native's correction arrives, the corrected offset maps into the spacer, and the cells on screen are unmounted. When the search disagrees with a mounted cell's measured frame about where the viewport starts, use the mounted cells that cover the viewport. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 99aefad commit 557172a

2 files changed

Lines changed: 166 additions & 0 deletions

File tree

‎packages/virtualized-lists/Lists/VirtualizeUtils.js‎

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,50 @@ export function elementsThatOverlapOffsets(
5858
return result;
5959
}
6060

61+
/**
62+
* The mounted cells in `range` that cover the viewport, found from their
63+
* measured frames, if the viewport starts in one of them.
64+
*/
65+
function mountedCellsInViewport(
66+
visibleBegin: number,
67+
visibleEnd: number,
68+
range: {first: number, last: number, ...},
69+
props: CellMetricProps,
70+
listMetrics: ListMetricsAggregator,
71+
zoomScale: number,
72+
): ?{first: number, last: number} {
73+
const lastIndex = Math.min(range.last, props.getItemCount(props.data) - 1);
74+
let left = Math.max(0, range.first);
75+
let right = lastIndex;
76+
while (left <= right) {
77+
const mid = left + Math.floor((right - left) / 2);
78+
const frame = listMetrics.getCellMetricsApprox(mid, props);
79+
if (visibleBegin < frame.offset * zoomScale) {
80+
right = mid - 1;
81+
} else if (visibleBegin >= (frame.offset + frame.length) * zoomScale) {
82+
left = mid + 1;
83+
} else {
84+
if (!frame.isMounted) {
85+
return null;
86+
}
87+
let last = mid;
88+
while (last < lastIndex) {
89+
const next = listMetrics.getCellMetrics(last + 1, props);
90+
if (
91+
next == null ||
92+
!next.isMounted ||
93+
next.offset * zoomScale >= visibleEnd
94+
) {
95+
break;
96+
}
97+
last++;
98+
}
99+
return {first: mid, last};
100+
}
101+
}
102+
return null;
103+
}
104+
61105
/**
62106
* Computes the number of elements in the `next` range that are new compared to the `prev` range.
63107
* Handy for calculating how many new items will be rendered when the render window changes so we
@@ -156,6 +200,36 @@ export function computeWindowedRenderLimits(
156200
listMetrics,
157201
zoomScale,
158202
);
203+
// An unmeasured cell is estimated from the current average cell length, but
204+
// a spacer keeps the size it was rendered with. Once the average changes,
205+
// the estimates for the cells in a spacer can overlap the rendered cells
206+
// after it, and the search can land on them instead: after a large prepend
207+
// under maintainVisibleContentPosition, that unmounts the cells on screen.
208+
// When the search disagrees with a mounted cell's measured frame about
209+
// where the viewport starts, use the mounted cells that cover it.
210+
const mounted = mountedCellsInViewport(
211+
visibleBegin,
212+
visibleEnd,
213+
prev,
214+
props,
215+
listMetrics,
216+
zoomScale,
217+
);
218+
if (
219+
mounted != null &&
220+
first !== mounted.first &&
221+
// At a boundary the search picks the earlier cell.
222+
first !== mounted.first - 1
223+
) {
224+
first = mounted.first;
225+
last = Math.max(last ?? mounted.last, mounted.last);
226+
if (overscanFirst != null) {
227+
overscanFirst = Math.min(overscanFirst, first);
228+
}
229+
if (overscanLast != null) {
230+
overscanLast = Math.max(overscanLast, last);
231+
}
232+
}
159233
overscanFirst = overscanFirst == null ? 0 : overscanFirst;
160234
first = first == null ? Math.max(0, overscanFirst) : first;
161235
overscanLast = overscanLast == null ? itemCount - 1 : overscanLast;

‎packages/virtualized-lists/Lists/__tests__/VirtualizedList-test.js‎

Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3048,6 +3048,98 @@ it('handles rapid prepends with coalesced scroll event (regression for #53542)',
30483048
).toBeGreaterThanOrEqual(0);
30493049
});
30503050

3051+
// Trigger: A large prepend at the top of the list. The prepended cells rendered above the spacer for the others
3052+
// are taller than average, and their layouts change the average cell length before maintainVisibleContentPosition's
3053+
// correction arrives. The spacer keeps its rendered size, but the estimates for its cells now overlap the window.
3054+
// Expected: The window computed from the corrected offset keeps the cells on screen rendered.
3055+
it('keeps the cells on screen when estimates for a spacer above them overlap them', async () => {
3056+
const items = generateItems(100);
3057+
const ITEM_HEIGHT = 10;
3058+
const PREPENDED_ITEM_HEIGHT = 100;
3059+
3060+
let component;
3061+
await act(() => {
3062+
component = create(
3063+
<VirtualizedList
3064+
initialNumToRender={2}
3065+
windowSize={1}
3066+
maintainVisibleContentPosition={{minIndexForVisible: 0}}
3067+
{...baseItemProps(items)}
3068+
/>,
3069+
);
3070+
});
3071+
const instance = component.getInstance();
3072+
const layoutCells = (data, indices, offsetOf, height) => {
3073+
for (const i of indices) {
3074+
simulateCellLayout(component, data, i, {
3075+
width: 10,
3076+
height,
3077+
x: 0,
3078+
y: offsetOf(i),
3079+
});
3080+
}
3081+
};
3082+
3083+
await act(() => {
3084+
simulateLayout(component, {
3085+
viewport: {width: 10, height: 50},
3086+
content: {width: 10, height: items.length * ITEM_HEIGHT},
3087+
});
3088+
layoutCells(items, [0, 1, 2, 3, 4], i => i * ITEM_HEIGHT, ITEM_HEIGHT);
3089+
performAllBatches();
3090+
});
3091+
await act(() => {
3092+
layoutCells(items, [0, 1, 2, 3, 4], i => i * ITEM_HEIGHT, ITEM_HEIGHT);
3093+
performAllBatches();
3094+
});
3095+
expect(instance.state.cellsAroundViewport).toEqual({first: 0, last: 4});
3096+
3097+
// Prepend 20 items. Cells 0 and 1 render for the initial region, then a
3098+
// spacer for cells 2-19 at the average, 18 * 10 = 180, then the window.
3099+
const newItems = [...generateItems(20, items.length), ...items];
3100+
await act(() => {
3101+
component.update(
3102+
<VirtualizedList
3103+
initialNumToRender={2}
3104+
windowSize={1}
3105+
maintainVisibleContentPosition={{minIndexForVisible: 0}}
3106+
{...baseItemProps(newItems)}
3107+
/>,
3108+
);
3109+
});
3110+
expect(instance.state.cellsAroundViewport).toEqual({first: 20, last: 24});
3111+
3112+
// Cells 0 and 1 are 100 tall, so cell 20 (the old cell 0) is laid out at
3113+
// 2 * 100 + 180 = 380. Their layouts raise the average to 250 / 7, which
3114+
// puts the estimate for cell 10 at 357-393. Then the scroll event with the
3115+
// correction.
3116+
const offset = 2 * PREPENDED_ITEM_HEIGHT + 18 * ITEM_HEIGHT;
3117+
await act(() => {
3118+
simulateContentLayout(component, {
3119+
width: 10,
3120+
height: offset + items.length * ITEM_HEIGHT,
3121+
});
3122+
layoutCells(
3123+
newItems,
3124+
[0, 1],
3125+
i => i * PREPENDED_ITEM_HEIGHT,
3126+
PREPENDED_ITEM_HEIGHT,
3127+
);
3128+
layoutCells(
3129+
newItems,
3130+
[20, 21, 22, 23, 24],
3131+
i => offset + (i - 20) * ITEM_HEIGHT,
3132+
ITEM_HEIGHT,
3133+
);
3134+
simulateScroll(component, {x: 0, y: offset});
3135+
performAllBatches();
3136+
});
3137+
3138+
const {first, last} = instance.state.cellsAroundViewport;
3139+
expect(first).toBeLessThanOrEqual(20);
3140+
expect(last).toBeGreaterThanOrEqual(24);
3141+
});
3142+
30513143
function generateItems(count, startKey = 0) {
30523144
return Array(count)
30533145
.fill()

0 commit comments

Comments
 (0)