Skip to content

fix(layout): keep tall masonry items in the visible range - #2461

Open
dennytosp wants to merge 1 commit into
Shopify:mainfrom
dennytosp:fix/masonry-visible-range
Open

fix(layout): keep tall masonry items in the visible range#2461
dennytosp wants to merge 1 commit into
Shopify:mainfrom
dennytosp:fix/masonry-visible-range

Conversation

@dennytosp

Copy link
Copy Markdown

Problem

RVLayoutManager.getVisibleLayouts() finds the first visible item by binary searching for the first index whose end passes the viewport start (findVisibleIndex.ts:38). A binary search needs that predicate to be monotonic in the index, which means item ends have to grow with the index.

They do in a linear or grid layout — each item starts where the previous row ended. They do not in masonry: an item is placed in the shortest column, so a tall item can start above the viewport and reach well past a short item placed after it. The search then settles on a later index, or walks past the tall item entirely and returns nothing, and every item from the tall one up to where it landed is dropped from the range.

That range is what gets rendered — EngagedIndicesTracker.updateScrollOffset() returns it as the engaged indices (EngagedIndicesTracker.ts:166) — so a dropped item is a blank space in its column. The drawDistance buffer hides it for ordinary item sizes, which is likely why it has been hard to pin down; an item taller than the viewport plus the buffer is not covered.

I found this while differential-testing getVisibleLayouts() against a brute force scan. Every layout manager, 1–4 columns, with and without spans, four item-size regimes, varied viewport heights, offsets swept past both ends — 665,568 windows:

layout mismatches before mismatches after
linear 0 0
grid (2, 3, 4 col) 0 0
masonry (2, 3, 4 col) 15,388 0

Linear and grid are exact both before and after, so the change is confined to the layout that was actually wrong. RecyclerViewManager.computeItemViewability() already routes masonry through the wider engaged range with the comment "Using higher buffer for masonry to avoid missing items" — the same symptom, worked around one layer up.

Fix

Item starts are still ordered in masonry, because an item goes at the height of the shortest column and column heights only grow. So search on those instead:

  1. The first item starting after the viewport begins is visible by definition, and bounds the answer from above.
  2. Walk back from there over the items that could still reach the viewport start.
  3. Stop once an item cannot reach it even at the tallest height seen — no earlier item can, since starts are ordered.

The tallest height is kept as a running maximum; over-estimating only widens the walk, it can never hide an item. Measured across 165,898 calls in the sweep: mean 4.1 iterations, max 15. Cost is one binary search swapped for another plus a handful of array reads.

Verification

src/__tests__/MasonryVisibleRange.test.ts — two hand-built cases, the differential sweep, and one test through EngagedIndicesTracker proving the item is actually engaged. All four fail on main and pass here:

test on main with fix
tall item hidden by a later short item fail pass
tall item the search walks straight past (empty range) fail pass
matches a full scan across columns / sizes / offsets fail pass
tall item the viewport is sitting inside is engaged fail pass

yarn test 191 passed / 15 suites, yarn type-check and yarn lint clean.

Not addressed

Related to #2103, but this does not fix that issue's reproduction. #2103 is reported with optimizeItemArrangement={false}, and that path fills columns in turn rather than by height, so item starts are unordered too:

optimizeItemArrangement: true   ys = [0, 0, 100, 190, 320, 380, 430, ...]   sorted
optimizeItemArrangement: false  ys = [0, 0, 100, 320, 190, 560, 250, ...]   not sorted

Neither binary search is valid there, and the same sweep still reports 4,072 / 7,861 windows wrong on that path. I left it going through the base implementation rather than guess at it — columns diverge without bound in sequential mode, so a correct fix needs a per-column search, which is a larger change than this one. The reporter of #2103 identified this same root cause; this PR fixes the half of it that sits on the default code path, which their repro does not exercise.

getVisibleLayouts() binary searches for the first item whose end passes
the viewport start. That needs item ends to grow with the index, which
holds for a linear or grid layout because each item starts where the
previous row ended. Masonry breaks it: an item goes in the shortest
column, so a tall one can start above the viewport and reach well past a
short item placed after it. The search settles on a later index, or on
nothing at all, and every item from the tall one up to it is dropped
from the engaged range and never renders.

Item starts are still ordered, since an item is placed at the height of
the shortest column and column heights only grow. Search on those
instead: the first item starting after the viewport begins is visible by
definition and bounds the answer from above, then walk back over the
items that could still reach the viewport start, stopping once one
cannot reach it even at the tallest height seen.

Sequential placement (optimizeItemArrangement: false) leaves item starts
unordered too, so neither search applies there; that path is untouched
and still goes through the base implementation.

Related to Shopify#2103
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant