Summary
In a chat-style list (maintainVisibleContentPosition + onStartReached prepending older messages), prepending items whose real height is much larger than their estimated height (e.g. messages taller than ~2 viewport heights) causes a visible jump / blank flash. Prepending short items works perfectly. Still reproduces on 3.3.2.
This is distinct from #463 (prepend while scrolled near the bottom) — that one is fixed for us since 3.1.0. The remaining case is specifically the size correction after real layouts land, and whether it reproduces is gated by estimate error vs. the draw buffer.
Environment
@legendapp/list 3.3.2
- react-native 0.83.6, new architecture, Expo SDK 55
- Observed on iOS (simulator + device); list uses
maintainVisibleContentPosition, initialScrollAtEnd, onStartReached loading older history
What we traced
-
Prepended rows enter layout with estimated sizes. When their real onLayout sizes land, prepareMVCP runs in a dataChanged: false pass anchored to the first visible item (mvcp.ts#L283-L286), and positionDiff comes out as the accumulated estimate error of the rows above the anchor — for items 2+ screens tall that's easily thousands of px in one pass.
-
requestAdjust applies the compensation immediately on the JS side — state.scroll += positionDiff plus moving the ScrollAdjust anchor view so native follows (requestAdjust.ts, ScrollAdjust.native.tsx).
-
Back in the same calculateItemsInView pass, the visible-window variables (scroll, scrollTopBuffered, scrollBottomBuffered) were computed before checkMVCP() ran, and are only recomputed for initial scroll or a programmatic scroll (calculateItemsInView.ts#L557-L567):
const scrollBeforeMVCP = state.scroll;
const scrollAdjustPendingBeforeMVCP = peek$(ctx, "scrollAdjustPending") ?? 0;
checkMVCP?.();
const didMVCPAdjustScroll = /* ... */;
if (didMVCPAdjustScroll && (initialScroll || state.scrollingTo)) {
updateScroll(state.scroll);
updateScrollRange();
}
-
So on a normal interactive-scroll pass, the rest of the pass mounts containers around a scroll value that is stale by exactly positionDiff:
- If the error is smaller than the render buffer (
scrollBufferTop/Bottom = 0.5–1.5 × drawDistance, calculateItemsInView.ts#L433-L443), the stale window still covers the viewport and the next scroll event silently corrects it — which is why short items look fine.
- If the error is much larger than the buffer (very tall items), the pass mounts items far from the actual viewport → visible jump / blank flash.
For context, blame shows the post-MVCP resync was added incrementally where bugs surfaced rather than as a deliberate exclusion of the interactive path: 7993dbf introduced it for initialScroll ("fix: recompute initial scroll range after MVCP"), and eca611e extended it to state.scrollingTo ("fix: handle MVCP end-target shrink clamp").
Suggested fix
Extend the gate so a pass also resyncs when the adjustment is too large for the render buffer to absorb:
const mvcpAdjustDelta =
state.scroll - scrollBeforeMVCP +
((peek$(ctx, "scrollAdjustPending") ?? 0) - scrollAdjustPendingBeforeMVCP);
if (
didMVCPAdjustScroll &&
(initialScroll ||
state.scrollingTo ||
Math.abs(mvcpAdjustDelta) > Math.min(scrollBufferTop, scrollBufferBottom))
) {
updateScroll(state.scroll);
updateScrollRange();
}
We're running exactly this as a local patch on top of 3.3.2 in a production chat app and it fixes the tall-item prepend jump. We previously tried resyncing unconditionally (dropping the gate entirely); that also fixes the jump but appeared to introduce side effects during normal scrolling, so the threshold version is deliberately conservative — small adjustments keep today's behavior, and only adjustments the buffer can't absorb trigger a recompute.
Repro sketch
LegendList with maintainVisibleContentPosition, initialScrollAtEnd, and onStartReached that prepends a page of items
getEstimatedItemSize returning ~100 while the prepended items' real rendered height is ~2 viewport heights (long multi-line text)
- Open the list scrolled to the end, scroll up to trigger
onStartReached → the list jumps when the real sizes land
- Same data with short items (real ≈ estimate) prepends cleanly
Happy to put together a runnable repro in the example app if that would help.
Summary
In a chat-style list (
maintainVisibleContentPosition+onStartReachedprepending older messages), prepending items whose real height is much larger than their estimated height (e.g. messages taller than ~2 viewport heights) causes a visible jump / blank flash. Prepending short items works perfectly. Still reproduces on 3.3.2.This is distinct from #463 (prepend while scrolled near the bottom) — that one is fixed for us since 3.1.0. The remaining case is specifically the size correction after real layouts land, and whether it reproduces is gated by estimate error vs. the draw buffer.
Environment
@legendapp/list3.3.2maintainVisibleContentPosition,initialScrollAtEnd,onStartReachedloading older historyWhat we traced
Prepended rows enter layout with estimated sizes. When their real
onLayoutsizes land,prepareMVCPruns in adataChanged: falsepass anchored to the first visible item (mvcp.ts#L283-L286), andpositionDiffcomes out as the accumulated estimate error of the rows above the anchor — for items 2+ screens tall that's easily thousands of px in one pass.requestAdjustapplies the compensation immediately on the JS side —state.scroll += positionDiffplus moving theScrollAdjustanchor view so native follows (requestAdjust.ts, ScrollAdjust.native.tsx).Back in the same
calculateItemsInViewpass, the visible-window variables (scroll,scrollTopBuffered,scrollBottomBuffered) were computed beforecheckMVCP()ran, and are only recomputed for initial scroll or a programmatic scroll (calculateItemsInView.ts#L557-L567):So on a normal interactive-scroll pass, the rest of the pass mounts containers around a scroll value that is stale by exactly
positionDiff:scrollBufferTop/Bottom= 0.5–1.5 ×drawDistance, calculateItemsInView.ts#L433-L443), the stale window still covers the viewport and the next scroll event silently corrects it — which is why short items look fine.For context, blame shows the post-MVCP resync was added incrementally where bugs surfaced rather than as a deliberate exclusion of the interactive path: 7993dbf introduced it for
initialScroll("fix: recompute initial scroll range after MVCP"), and eca611e extended it tostate.scrollingTo("fix: handle MVCP end-target shrink clamp").Suggested fix
Extend the gate so a pass also resyncs when the adjustment is too large for the render buffer to absorb:
We're running exactly this as a local patch on top of 3.3.2 in a production chat app and it fixes the tall-item prepend jump. We previously tried resyncing unconditionally (dropping the gate entirely); that also fixes the jump but appeared to introduce side effects during normal scrolling, so the threshold version is deliberately conservative — small adjustments keep today's behavior, and only adjustments the buffer can't absorb trigger a recompute.
Repro sketch
LegendListwithmaintainVisibleContentPosition,initialScrollAtEnd, andonStartReachedthat prepends a page of itemsgetEstimatedItemSizereturning ~100 while the prepended items' real rendered height is ~2 viewport heights (long multi-line text)onStartReached→ the list jumps when the real sizes landHappy to put together a runnable repro in the example app if that would help.