Skip to content

Prepending items much taller than their estimated size jumps the list: MVCP adjustment isn't applied to the in-flight calculateItemsInView pass #491

Description

@zmzhuai

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

  1. 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.

  2. 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).

  3. 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();
    }
  4. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions