Skip to content

v2 inverted on react-native-web freezes virtualization (permanent blank list, onEndReached never fires): firstItemOffset measurement ignores the scaleY(-1) transform #2380

Description

@Leppard

Current behavior

On react-native-web, an inverted FlashList v2 freezes its virtualization window entirely:

  • The set of mounted rows never changes while scrolling — scroll past the initially rendered region and you get a permanent blank viewport (it never self-heals, even after waiting).
  • onEndReached never fires, so paginated "load older messages" chat UIs never load anything on web.
  • Occasionally the scroll position teleports backwards (the engine writes a corrected offset computed from a corrupted measurement, see analysis below).

Native (iOS/Android) is unaffected.

Expected behavior

Scrolling an inverted list on web should recycle rows and fire onEndReached, like it does on native.

Root cause analysis

We debugged this down to a specific measurement bug and verified the fix.

measureFirstChildLayout() in src/recyclerview/utils/measureLayout.web.ts computes the first-child offset from getBoundingClientRect() deltas plus scroll compensation. getBoundingClientRect() is affected by CSS transforms, and inverted on web applies scaleY(-1) to the scroller (PlatformConfig.invertedTransformStyle). The flip mirrors the painted position of the bounded-size marker view, so the measured y:

  • is wrong at rest: with a content padding of 8px and a 743px viewport we measure firstItemOffset = 735 (i.e. 743 − 8, the flipped position) instead of 8;
  • grows with scrolling: after scrolling to scrollTop = 1200 a re-measure returns 3135 (≈ 735 + 2×1200, rect delta + the scrollY compensation term both move).

This value is fed into RecyclerViewManager.updateLayoutParams(..., firstItemOffset), and every scroll offset is then adjusted by it (offset - this.firstItemOffset) before reaching EngagedIndicesTracker.updateScrollOffset. With the corrupted value, real DOM scrollTop = 3600 becomes an internal tracker offset of -4335. getVisibleLayouts(-4335, …)'s binary search finds nothing, so the engaged indices never update, the render window stays frozen at whatever was mounted initially, and checkBounds() never sees the list end (hence dead onEndReached).

Measured internal state at the moment the blank screen shows (instrumented RecyclerViewManager):

DOM scrollTop tracker scrollOffset firstItemOffset engaged indices layout total height
0 (initial) −735 735 0–19 (correct-ish by luck) 5127 (correct)
3600 −4335 ~7935 (re-measured while scrolled) 0–19 (frozen) 5127 (correct)

Calling layoutManager.getVisibleLayouts(3300, 5043) manually at that moment returns the correct 35–54, confirming the layout data is fine and only the offset input is poisoned.

Notes from variable isolation: maintainVisibleContentPosition enabled vs {disabled: true} makes no difference — the freeze reproduces either way. Native wheel scrolling and programmatic scrollTop writes reproduce identically.

Suggested fix (verified)

Use layout-box geometry (offsetTop/offsetLeft walked up the offsetParent chain), which ignores CSS transforms, and keep the rect-based math as a fallback when the chain doesn't pass through parentView:

export function measureFirstChildLayout(
  childContainerView: Element,
  parentView: Element
): Layout {
  const childRect = childContainerView.getBoundingClientRect();

  // getBoundingClientRect is transform-affected. With `inverted` (scaleY(-1)
  // on the scroller) the measured offset becomes flipped AND scroll-dependent,
  // poisoning every scroll offset fed to the virtualizer: the render window
  // freezes -> blank list while scrolling, and onEndReached never fires on web.
  // offsetTop/offsetLeft use layout-box geometry which ignores transforms.
  let x = 0;
  let y = 0;
  let el: Element | null = childContainerView;
  let reached = false;
  while (el) {
    if (el === parentView) {
      reached = true;
      break;
    }
    const htmlEl = el as HTMLElement;
    x += htmlEl.offsetLeft ?? 0;
    y += htmlEl.offsetTop ?? 0;
    el = htmlEl.offsetParent;
  }
  if (reached) {
    return { x, y, width: roundOffPixel(childRect.width), height: roundOffPixel(childRect.height) };
  }

  // Fallback: original rect-based math
  const parentRect = parentView.getBoundingClientRect();
  const scrollOffsets = getScrollOffsets(childContainerView, parentView);
  return {
    x: childRect.left - parentRect.left + scrollOffsets.scrollX,
    y: childRect.top - parentRect.top + scrollOffsets.scrollY,
    width: roundOffPixel(childRect.width),
    height: roundOffPixel(childRect.height),
  };
}

We're shipping this as a pnpm patch in production. After the fix, the same repro scrolls smoothly end to end: rows recycle (engaged window moves 0–19 → 36–54), no blank regions, and onEndReached fires at the oldest end. Happy to open a PR if this direction looks right.

To Reproduce

Single-file repro (bundle with esbuild or any RNW setup):

import React from 'react';
import { createRoot } from 'react-dom/client';
import { Text, View } from 'react-native'; // aliased to react-native-web
import { FlashList } from '@shopify/flash-list';

const DATA = Array.from({ length: 55 }, (_, i) => ({
  id: `m${i}`,
  text: `#${i} ` + 'variable height row content. '.repeat(1 + ((i * 7) % 5)),
}));

function App() {
  return (
    <View style={{ height: 745, width: 420 }}>
      <FlashList
        data={DATA}
        renderItem={({ item }) => (
          <View style={{ marginBottom: 8, padding: 10, backgroundColor: '#eee' }}>
            <Text>{item.text}</Text>
          </View>
        )}
        keyExtractor={(item) => item.id}
        inverted
        onEndReached={() => console.log('onEndReached')} // never fires
        onEndReachedThreshold={0.3}
        contentContainerStyle={{ paddingVertical: 8 }}
      />
    </View>
  );
}

createRoot(document.getElementById('root')).render(<App />);
  1. Open in a browser (Chrome or WKWebView — both reproduce).
  2. Scroll toward older items (wheel down; note wheel direction is mirrored by the scaleY(-1) flip).
  3. After ~1.5 viewports the list turns blank and stays blank; onEndReached is never logged.

Platform

  • iOS
  • Android
  • Web (react-native-web)

Environment

  • @shopify/flash-list: 2.3.2 (latest at time of writing)
  • react-native-web: 0.21.2
  • react / react-dom: 19.1.0
  • Browsers: Chrome 133 (headless + headed), WKWebView (macOS wry/Tauri)

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