Skip to content

fix(layout): avoid binary search for masonry visible item detection#2207

Open
shopify-github-actions-access[bot] wants to merge 3 commits intomainfrom
fix/issue-2103-masonry-disappearing-items
Open

fix(layout): avoid binary search for masonry visible item detection#2207
shopify-github-actions-access[bot] wants to merge 3 commits intomainfrom
fix/issue-2103-masonry-disappearing-items

Conversation

@shopify-github-actions-access
Copy link
Copy Markdown
Contributor

Description

In masonry layout, the base class getVisibleLayouts uses binary search to find visible items, which assumes y-positions are sorted by index. However, masonry places items in columns, so y-positions are NOT monotonically sorted by index — items in a shorter column can have much lower y-positions than earlier items in a taller column. This causes the binary search to miss visible items, making them disappear during scrolling.

The fix overrides getVisibleLayouts in MasonryLayoutManager to scan all items and correctly detect viewport overlap, regardless of index ordering. This is especially noticeable with optimizeItemArrangement={false} where column height divergence is more pronounced.

Fixes #2103

Reviewers' hat-rack 🎩

  • The override in MasonryLayoutManager.getVisibleLayouts uses a linear scan instead of binary search. This is correct because masonry y-positions are unsorted by index. For typical masonry lists (hundreds to low thousands of items), this has negligible performance impact.
  • Verify the new unit tests cover the key edge case: divergent column heights causing unsorted y-positions.

Test plan

  • Unit tests pass (yarn test)
  • Type check passes (yarn type-check)
  • New tests added for masonry visibility with unsorted y-positions
  • Verified binary search failure scenario is covered by tests

The base class getVisibleLayouts uses binary search which assumes items
are sorted by y-position. In masonry layout, items are placed in columns
so y-positions are NOT monotonically sorted by index. This caused items
to disappear during scrolling, especially with optimizeItemArrangement
disabled where column height divergence is more pronounced.

Override getVisibleLayouts in MasonryLayoutManager to scan all items and
correctly identify which ones overlap with the viewport, regardless of
their index ordering.

Fixes #2103
@naqvitalha
Copy link
Copy Markdown
Collaborator

@agent The linear scan might be too slow. Can we not degrade performance? FIx this and also test and fix lint.

@github-actions
Copy link
Copy Markdown
Contributor

github-actions bot commented Mar 24, 2026

Claude finished @naqvitalha's task in 4m 24s —— View job


Optimized masonry visible item detection

  • Read current implementation and understand the architecture
  • Replace linear scan with per-column binary search for O(numColumns × log(n/numColumns)) performance
  • Update tests
  • Run tests, type-check, and lint
  • Push changes

What changed: Replaced the O(n) linear scan in getVisibleLayouts with per-column binary search:

  1. columnItems: number[][] — per-column sorted item index arrays, rebuilt during recomputeLayouts (which already iterates all items, so no extra cost)
  2. rebuildColumnItems() — builds column-to-item mappings from layout x-positions
  3. getVisibleLayouts() — binary searches each column independently for visible items, then returns ConsecutiveNumbers(min, max) across all columns

Performance: O(numColumns × log(n/numColumns)) per scroll event instead of O(n). For a typical 2-column masonry with 10k items, this is ~28 comparisons vs 10,000.

Why it works: Within each masonry column, y-positions are monotonically non-decreasing (column heights only increase during placement), so binary search is valid per-column even though the global index order is unsorted.

All 185 tests pass, type-check and lint clean.
|

…ction

Replace O(n) linear scan with per-column binary search for
O(numColumns * log(n/numColumns)) performance. Within each masonry
column, y-positions are monotonically sorted, so binary search is valid.
Column-to-item mappings are built during recomputeLayouts (which already
iterates all items) so the visibility hot path stays fast during scrolling.

Co-authored-by: Talha Naqvi <naqvitalha@users.noreply.github.com>
Copy link
Copy Markdown

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR fixes Masonry layout visibility detection where the base RVLayoutManager.getVisibleLayouts uses a binary search that assumes layouts are ordered by y-position, which is not true for masonry columns—causing items to disappear while scrolling (Issue #2103).

Changes:

  • Adds a RVMasonryLayoutManagerImpl.getVisibleLayouts override that finds visible items via per-column visibility search (instead of a single global binary search).
  • Introduces columnItems and rebuilds per-column item mappings after layout recomputation.
  • Adds unit tests covering the unsorted-y-by-index masonry visibility scenario.

Reviewed changes

Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.

File Description
src/recyclerview/layout-managers/MasonryLayoutManager.ts Adds per-column visibility detection and column-to-item index mappings.
src/__tests__/MasonryLayoutManager.test.ts Adds tests ensuring visible items aren’t missed when masonry y-positions are unsorted by index.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +392 to +398
const mid = (lo + hi) >>> 1;
const layout = this.layouts[items[mid]];
if (layout.y < unboundDimensionEnd) {
colLast = mid;
lo = mid + 1;
} else {
hi = mid - 1;
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Visibility boundary semantics here differ from the base RVLayoutManager implementation (findLastVisibleIndex treats position <= unboundDimensionEnd as visible). Using layout.y < unboundDimensionEnd may exclude items that start exactly at unboundDimensionEnd, potentially changing engaged/visible index behavior compared to other layout managers. Align this comparison with the base implementation to keep consistent edge handling across layout managers.

Copilot uses AI. Check for mistakes.
Comment on lines +348 to +354
/**
* Override getVisibleLayouts to use per-column binary search instead of
* a single binary search on the full layouts array. In masonry layout,
* y-positions are NOT sorted by index (items are placed in columns), but
* they ARE sorted within each column. Binary searching each column gives
* O(numColumns * log(n/numColumns)) performance.
*/
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The PR title/description says masonry visibility detection is fixed by “avoiding binary search” / doing a “linear scan”, but this implementation still uses binary search (per-column). Either update the PR description/title to match what’s implemented, or adjust the implementation/inline comment so they’re consistent.

Copilot uses AI. Check for mistakes.
Comment on lines +355 to +367
getVisibleLayouts(
unboundDimensionStart: number,
unboundDimensionEnd: number
): ConsecutiveNumbers {
if (this.layouts.length === 0) {
return ConsecutiveNumbers.EMPTY;
}

let firstVisibleIndex = -1;
let lastVisibleIndex = -1;

for (const items of this.columnItems) {
if (items.length === 0) continue;
Copy link

Copilot AI Mar 24, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getVisibleLayouts relies on this.columnItems, but columnItems is only rebuilt in recomputeLayouts(). When a RVMasonryLayoutManagerImpl is constructed with a previousLayoutManager (see RecyclerViewManager.updateLayoutParams passing the old manager into the new one), this.layouts can be non-empty while columnItems is still [], causing this method to incorrectly return EMPTY and potentially render nothing. Consider lazily calling rebuildColumnItems() when columnItems.length !== this.maxColumns (or when the total count across columns doesn’t match this.layouts.length), and/or rebuilding once in the constructor when this.layouts.length > 0.

Copilot uses AI. Check for mistakes.
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.

Disappearing items in Masonry layout

2 participants