/**
 * Copyright (c) Meta Platforms, Inc. and affiliates.
 *
 * This source code is licensed under the MIT license found in the
 * LICENSE file in the root directory of this source tree.
 *
 * @flow
 * @format
 */

import type {CellMetricProps, ListOrientation} from './ListMetricsAggregator';
import type {ViewToken} from './ViewabilityHelper';
import type {
  Item,
  Props,
  RenderItemProps,
  RenderItemType,
  Separators,
} from './VirtualizedListProps';
import type {ScrollResponderType} from 'react-native/Libraries/Components/ScrollView/ScrollView';
import type {ViewStyleProp} from 'react-native/Libraries/StyleSheet/StyleSheet';
import type {
  LayoutEvent,
  ScrollEvent,
} from 'react-native/Libraries/Types/CoreEventTypes';

import Batchinator from '../Interaction/Batchinator';
import clamp from '../Utilities/clamp';
import infoLog from '../Utilities/infoLog';
import {CellRenderMask} from './CellRenderMask';
import ChildListCollection from './ChildListCollection';
import FillRateHelper from './FillRateHelper';
import ListMetricsAggregator from './ListMetricsAggregator';
import StateSafePureComponent from './StateSafePureComponent';
import ViewabilityHelper from './ViewabilityHelper';
import CellRenderer from './VirtualizedListCellRenderer';
import {
  VirtualizedListCellContextProvider,
  VirtualizedListContext,
  VirtualizedListContextProvider,
} from './VirtualizedListContext.js';
import {
  horizontalOrDefault,
  initialNumToRenderOrDefault,
  maxToRenderPerBatchOrDefault,
  onEndReachedThresholdOrDefault,
  onStartReachedThresholdOrDefault,
  windowSizeOrDefault,
} from './VirtualizedListProps';
import {
  computeWindowedRenderLimits,
  keyExtractor as defaultKeyExtractor,
} from './VirtualizeUtils';
import invariant from 'invariant';
import nullthrows from 'nullthrows';
import * as React from 'react';
import {
  I18nManager,
  Platform,
  RefreshControl,
  ScrollView,
  StyleSheet,
  View,
  findNodeHandle,
} from 'react-native';

export type {RenderItemProps, RenderItemType, Separators};

const ON_EDGE_REACHED_EPSILON = 0.001;

let _usedIndexForKey = false;
let _keylessItemComponentName: string = '';

type ViewabilityHelperCallbackTuple = {
  viewabilityHelper: ViewabilityHelper,
  onViewableItemsChanged: (info: {
    viewableItems: Array<ViewToken>,
    changed: Array<ViewToken>,
    ...
  }) => void,
  ...
};

type State = {
  renderMask: CellRenderMask,
  cellsAroundViewport: {first: number, last: number},
  // Used to track items added at the start of the list for maintainVisibleContentPosition.
  firstVisibleItemKey: ?string,
  // When > 0 the scroll position available in JS is considered stale and should not be used.
  pendingScrollUpdateCount: number,
};

function getScrollingThreshold(threshold: number, visibleLength: number) {
  return (threshold * visibleLength) / 2;
}

/**
 * Base implementation for the more convenient [`<FlatList>`](https://reactnative.dev/docs/flatlist)
 * and [`<SectionList>`](https://reactnative.dev/docs/sectionlist) components, which are also better
 * documented. In general, this should only really be used if you need more flexibility than
 * `FlatList` provides, e.g. for use with immutable data instead of plain arrays.
 *
 * Virtualization massively improves memory consumption and performance of large lists by
 * maintaining a finite render window of active items and replacing all items outside of the render
 * window with appropriately sized blank space. The window adapts to scrolling behavior, and items
 * are rendered incrementally with low-pri (after any running interactions) if they are far from the
 * visible area, or with hi-pri otherwise to minimize the potential of seeing blank space.
 *
 * Some caveats:
 *
 * - Internal state is not preserved when content scrolls out of the render window. Make sure all
 *   your data is captured in the item data or external stores like Flux, Redux, or Relay.
 * - This is a `PureComponent` which means that it will not re-render if `props` remain shallow-
 *   equal. Make sure that everything your `renderItem` function depends on is passed as a prop
 *   (e.g. `extraData`) that is not `===` after updates, otherwise your UI may not update on
 *   changes. This includes the `data` prop and parent component state.
 * - In order to constrain memory and enable smooth scrolling, content is rendered asynchronously
 *   offscreen. This means it's possible to scroll faster than the fill rate ands momentarily see
 *   blank content. This is a tradeoff that can be adjusted to suit the needs of each application,
 *   and we are working on improving it behind the scenes.
 * - By default, the list looks for a `key` or `id` prop on each item and uses that for the React key.
 *   Alternatively, you can provide a custom `keyExtractor` prop.
 * - As an effort to remove defaultProps, use helper functions when referencing certain props
 *
 */
class VirtualizedList extends StateSafePureComponent<Props, State> {
  static contextType: typeof VirtualizedListContext = VirtualizedListContext;

  // scrollToEnd may be janky without getItemLayout prop
  scrollToEnd(params?: ?{animated?: ?boolean, ...}) {
    const animated = params ? params.animated : true;
    const veryLast = this.props.getItemCount(this.props.data) - 1;
    if (veryLast < 0) {
      return;
    }
    const frame = this._listMetrics.getCellMetricsApprox(veryLast, this.props);
    const offset = Math.max(
      0,
      frame.offset +
        frame.length +
        this._footerLength -
        this._scrollMetrics.visibleLength,
    );

    // TODO: consider using `ref.scrollToEnd` directly
    this.scrollToOffset({animated, offset});
  }

  // scrollToIndex may be janky without getItemLayout prop
  scrollToIndex(params: {
    animated?: ?boolean,
    index: number,
    viewOffset?: number,
    viewPosition?: number,
    ...
  }): $FlowFixMe {
    const {data, getItemCount, getItemLayout, onScrollToIndexFailed} =
      this.props;
    const {animated, index, viewOffset, viewPosition} = params;
    invariant(
      index >= 0,
      `scrollToIndex out of range: requested index ${index} but minimum is 0`,
    );
    invariant(
      getItemCount(data) >= 1,
      `scrollToIndex out of range: item length ${getItemCount(
        data,
      )} but minimum is 1`,
    );
    invariant(
      index < getItemCount(data),
      `scrollToIndex out of range: requested index ${index} is out of 0 to ${
        getItemCount(data) - 1
      }`,
    );
    if (
      !getItemLayout &&
      index > this._listMetrics.getHighestMeasuredCellIndex()
    ) {
      invariant(
        !!onScrollToIndexFailed,
        'scrollToIndex should be used in conjunction with getItemLayout or onScrollToIndexFailed, ' +
          'otherwise there is no way to know the location of offscreen indices or handle failures.',
      );
      onScrollToIndexFailed({
        averageItemLength: this._listMetrics.getAverageCellLength(),
        highestMeasuredFrameIndex:
          this._listMetrics.getHighestMeasuredCellIndex(),
        index,
      });
      return;
    }
    const frame = this._listMetrics.getCellMetricsApprox(
      Math.floor(index),
      this.props,
    );
    const offset =
      Math.max(
        0,
        this._listMetrics.getCellOffsetApprox(index, this.props) -
          (viewPosition || 0) *
            (this._scrollMetrics.visibleLength - frame.length),
      ) - (viewOffset || 0);

    this.scrollToOffset({offset, animated});
  }

  // scrollToItem may be janky without getItemLayout prop. Required linear scan through items -
  // use scrollToIndex instead if possible.
  scrollToItem(params: {
    animated?: ?boolean,
    item: Item,
    viewOffset?: number,
    viewPosition?: number,
    ...
  }) {
    const {item} = params;
    const {data, getItem, getItemCount} = this.props;
    const itemCount = getItemCount(data);
    for (let index = 0; index < itemCount; index++) {
      if (getItem(data, index) === item) {
        this.scrollToIndex({...params, index});
        break;
      }
    }
  }

  /**
   * Scroll to a specific content pixel offset in the list.
   *
   * Param `offset` expects the offset to scroll to.
   * In case of `horizontal` is true, the offset is the x-value,
   * in any other case the offset is the y-value.
   *
   * Param `animated` (`true` by default) defines whether the list
   * should do an animation while scrolling.
   */
  scrollToOffset(params: {animated?: ?boolean, offset: number, ...}) {
    const {animated, offset} = params;
    const scrollRef = this._scrollRef;

    if (scrollRef == null) {
      return;
    }

    if (scrollRef.scrollTo == null) {
      console.warn(
        'No scrollTo method provided. This may be because you have two nested ' +
          'VirtualizedLists with the same orientation, or because you are ' +
          'using a custom component that does not implement scrollTo.',
      );
      return;
    }

    const {horizontal, rtl} = this._orientation();
    if (horizontal && rtl && !this._listMetrics.hasContentLength()) {
      console.warn(
        'scrollToOffset may not be called in RTL before content is laid out',
      );
      return;
    }

    scrollRef.scrollTo({
      animated,
      ...this._scrollToParamsFromOffset(offset),
    });
  }

  _scrollToParamsFromOffset(offset: number): {x?: number, y?: number} {
    const {horizontal, rtl} = this._orientation();
    if (horizontal && rtl) {
      // Add the visible length of the scrollview so that the offset is right-aligned
      const cartOffset = this._listMetrics.cartesianOffset(
        offset + this._scrollMetrics.visibleLength,
      );
      return horizontal ? {x: cartOffset} : {y: cartOffset};
    } else {
      return horizontal ? {x: offset} : {y: offset};
    }
  }

  recordInteraction() {
    this._nestedChildLists.forEach(childList => {
      childList.recordInteraction();
    });
    this._viewabilityTuples.forEach(t => {
      t.viewabilityHelper.recordInteraction();
    });
    this._updateViewableItems(this.props, this.state.cellsAroundViewport);
  }

  flashScrollIndicators() {
    if (this._scrollRef == null) {
      return;
    }

    this._scrollRef.flashScrollIndicators();
  }

  /**
   * Provides a handle to the underlying scroll responder.
   * Note that `this._scrollRef` might not be a `ScrollView`, so we
   * need to check that it responds to `getScrollResponder` before calling it.
   */
  getScrollResponder(): ?ScrollResponderType {
    if (this._scrollRef && this._scrollRef.getScrollResponder) {
      return this._scrollRef.getScrollResponder();
    }
  }

  getScrollableNode(): ?number {
    if (this._scrollRef && this._scrollRef.getScrollableNode) {
      return this._scrollRef.getScrollableNode();
    } else {
      return findNodeHandle(this._scrollRef);
    }
  }

  getScrollRef():
    | ?React.ElementRef<typeof ScrollView>
    | ?React.ElementRef<typeof View> {
    if (this._scrollRef && this._scrollRef.getScrollRef) {
      return this._scrollRef.getScrollRef();
    } else {
      return this._scrollRef;
    }
  }

  setNativeProps(props: Object) {
    if (this._scrollRef) {
      this._scrollRef.setNativeProps(props);
    }
  }

  _getCellKey(): string {
    return this.context?.cellKey || 'rootList';
  }

  // $FlowFixMe[missing-local-annot]
  _getScrollMetrics = () => {
    return this._scrollMetrics;
  };

  hasMore(): boolean {
    return this._hasMore;
  }

  // $FlowFixMe[missing-local-annot]
  _getOutermostParentListRef = () => {
    if (this._isNestedWithSameOrientation()) {
      return this.context.getOutermostParentListRef();
    } else {
      return this;
    }
  };

  _registerAsNestedChild = (childList: {
    cellKey: string,
    ref: VirtualizedList,
  }): void => {
    this._nestedChildLists.add(childList.ref, childList.cellKey);
    if (this._hasInteracted) {
      childList.ref.recordInteraction();
    }
  };

  _unregisterAsNestedChild = (childList: {ref: VirtualizedList}): void => {
    this._nestedChildLists.remove(childList.ref);
  };

  state: State;

  constructor(props: Props) {
    super(props);
    this._checkProps(props);

    this._fillRateHelper = new FillRateHelper(this._listMetrics);
    this._updateCellsToRenderBatcher = new Batchinator(
      this._updateCellsToRender,
      this.props.updateCellsBatchingPeriod ?? 50,
    );

    if (this.props.viewabilityConfigCallbackPairs) {
      this._viewabilityTuples = this.props.viewabilityConfigCallbackPairs.map(
        pair => ({
          viewabilityHelper: new ViewabilityHelper(pair.viewabilityConfig),
          onViewableItemsChanged: pair.onViewableItemsChanged,
        }),
      );
    } else {
      const {onViewableItemsChanged, viewabilityConfig} = this.props;
      if (onViewableItemsChanged) {
        this._viewabilityTuples.push({
          viewabilityHelper: new ViewabilityHelper(viewabilityConfig),
          onViewableItemsChanged: onViewableItemsChanged,
        });
      }
    }

    const initialRenderRegion = VirtualizedList._initialRenderRegion(props);

    const minIndexForVisible =
      this.props.maintainVisibleContentPosition?.minIndexForVisible ?? 0;

    this.state = {
      cellsAroundViewport: initialRenderRegion,
      renderMask: VirtualizedList._createRenderMask(props, initialRenderRegion),
      firstVisibleItemKey:
        this.props.getItemCount(this.props.data) > minIndexForVisible
          ? VirtualizedList._getItemKey(this.props, minIndexForVisible)
          : null,
      // When we have a non-zero initialScrollIndex, we will receive a
      // scroll event later so this will prevent the window from updating
      // until we get a valid offset.
      pendingScrollUpdateCount:
        this.props.initialScrollIndex != null &&
        this.props.initialScrollIndex > 0
          ? 1
          : 0,
    };
  }

  _checkProps(props: Props) {
    const {onScroll, windowSize, getItemCount, data, initialScrollIndex} =
      props;

    invariant(
      // $FlowFixMe[prop-missing]
      !onScroll || !onScroll.__isNative,
      'Components based on VirtualizedList must be wrapped with Animated.createAnimatedComponent ' +
        'to support native onScroll events with useNativeDriver',
    );
    invariant(
      windowSizeOrDefault(windowSize) > 0,
      'VirtualizedList: The windowSize prop must be present and set to a value greater than 0.',
    );

    invariant(
      getItemCount,
      'VirtualizedList: The "getItemCount" prop must be provided',
    );

    const itemCount = getItemCount(data);

    if (
      initialScrollIndex != null &&
      !this._hasTriggeredInitialScrollToIndex &&
      (initialScrollIndex < 0 ||
        (itemCount > 0 && initialScrollIndex >= itemCount)) &&
      !this._hasWarned.initialScrollIndex
    ) {
      console.warn(
        `initialScrollIndex "${initialScrollIndex}" is not valid (list has ${itemCount} items)`,
      );
      this._hasWarned.initialScrollIndex = true;
    }

    if (__DEV__ && !this._hasWarned.flexWrap) {
      // $FlowFixMe[underconstrained-implicit-instantiation]
      const flatStyles = StyleSheet.flatten(this.props.contentContainerStyle);
      if (flatStyles != null && flatStyles.flexWrap === 'wrap') {
        console.warn(
          '`flexWrap: `wrap`` is not supported with the `VirtualizedList` components.' +
            'Consider using `numColumns` with `FlatList` instead.',
        );
        this._hasWarned.flexWrap = true;
      }
    }
  }

  static _findItemIndexWithKey(
    props: Props,
    key: string,
    hint: ?number,
  ): ?number {
    const itemCount = props.getItemCount(props.data);
    if (hint != null && hint >= 0 && hint < itemCount) {
      const curKey = VirtualizedList._getItemKey(props, hint);
      if (curKey === key) {
        return hint;
      }
    }
    for (let ii = 0; ii < itemCount; ii++) {
      const curKey = VirtualizedList._getItemKey(props, ii);
      if (curKey === key) {
        return ii;
      }
    }
    return null;
  }

  static _getItemKey(
    props: {
      data: Props['data'],
      getItem: Props['getItem'],
      keyExtractor: Props['keyExtractor'],
      ...
    },
    index: number,
  ): string {
    const item = props.getItem(props.data, index);
    return VirtualizedList._keyExtractor(item, index, props);
  }

  static _createRenderMask(
    props: Props,
    cellsAroundViewport: {first: number, last: number},
    additionalRegions?: ?$ReadOnlyArray<{first: number, last: number}>,
  ): CellRenderMask {
    const itemCount = props.getItemCount(props.data);

    invariant(
      cellsAroundViewport.first >= 0 &&
        cellsAroundViewport.last >= cellsAroundViewport.first - 1 &&
        cellsAroundViewport.last < itemCount,
      `Invalid cells around viewport "[${cellsAroundViewport.first}, ${cellsAroundViewport.last}]" was passed to VirtualizedList._createRenderMask`,
    );

    const renderMask = new CellRenderMask(itemCount);

    if (itemCount > 0) {
      const allRegions = [cellsAroundViewport, ...(additionalRegions ?? [])];
      for (const region of allRegions) {
        renderMask.addCells(region);
      }

      // The initially rendered cells are retained as part of the
      // "scroll-to-top" optimization
      if (props.initialScrollIndex == null || props.initialScrollIndex <= 0) {
        const initialRegion = VirtualizedList._initialRenderRegion(props);
        renderMask.addCells(initialRegion);
      }

      // The layout coordinates of sticker headers may be off-screen while the
      // actual header is on-screen. Keep the most recent before the viewport
      // rendered, even if its layout coordinates are not in viewport.
      const stickyIndicesSet = new Set(props.stickyHeaderIndices);
      VirtualizedList._ensureClosestStickyHeader(
        props,
        stickyIndicesSet,
        renderMask,
        cellsAroundViewport.first,
      );
    }

    return renderMask;
  }

  static _initialRenderRegion(props: Props): {first: number, last: number} {
    const itemCount = props.getItemCount(props.data);

    const firstCellIndex = Math.max(
      0,
      Math.min(itemCount - 1, Math.floor(props.initialScrollIndex ?? 0)),
    );

    const lastCellIndex =
      Math.min(
        itemCount,
        firstCellIndex + initialNumToRenderOrDefault(props.initialNumToRender),
      ) - 1;

    return {
      first: firstCellIndex,
      last: lastCellIndex,
    };
  }

  static _ensureClosestStickyHeader(
    props: Props,
    stickyIndicesSet: Set<number>,
    renderMask: CellRenderMask,
    cellIdx: number,
  ) {
    const stickyOffset = props.ListHeaderComponent ? 1 : 0;

    for (let itemIdx = cellIdx - 1; itemIdx >= 0; itemIdx--) {
      if (stickyIndicesSet.has(itemIdx + stickyOffset)) {
        renderMask.addCells({first: itemIdx, last: itemIdx});
        break;
      }
    }
  }

  _adjustCellsAroundViewport(
    props: Props,
    cellsAroundViewport: {first: number, last: number},
    pendingScrollUpdateCount: number,
  ): {first: number, last: number} {
    const {data, getItemCount} = props;
    const onEndReachedThreshold = onEndReachedThresholdOrDefault(
      props.onEndReachedThreshold,
    );
    const {offset, visibleLength} = this._scrollMetrics;
    const contentLength = this._listMetrics.getContentLength();
    const distanceFromEnd = contentLength - visibleLength - offset;

    // Wait until the scroll view metrics have been set up. And until then,
    // we will trust the initialNumToRender suggestion
    if (visibleLength <= 0 || contentLength <= 0) {
      return cellsAroundViewport.last >= getItemCount(data)
        ? VirtualizedList._constrainToItemCount(cellsAroundViewport, props)
        : cellsAroundViewport;
    }

    let newCellsAroundViewport: {first: number, last: number};
    if (props.disableVirtualization) {
      const renderAhead =
        distanceFromEnd < onEndReachedThreshold * visibleLength
          ? maxToRenderPerBatchOrDefault(props.maxToRenderPerBatch)
          : 0;

      newCellsAroundViewport = {
        first: 0,
        last: Math.min(
          cellsAroundViewport.last + renderAhead,
          getItemCount(data) - 1,
        ),
      };
    } else {
      // If we have a pending scroll update, we should not adjust the render window as it
      // might override the correct window.
      if (pendingScrollUpdateCount > 0) {
        return cellsAroundViewport.last >= getItemCount(data)
          ? VirtualizedList._constrainToItemCount(cellsAroundViewport, props)
          : cellsAroundViewport;
      }

      newCellsAroundViewport = computeWindowedRenderLimits(
        props,
        maxToRenderPerBatchOrDefault(props.maxToRenderPerBatch),
        windowSizeOrDefault(props.windowSize),
        cellsAroundViewport,
        this._listMetrics,
        this._scrollMetrics,
      );
      invariant(
        newCellsAroundViewport.last < getItemCount(data),
        'computeWindowedRenderLimits() should return range in-bounds',
      );
    }

    if (this._nestedChildLists.size() > 0) {
      // If some cell in the new state has a child list in it, we should only render
      // up through that item, so that we give that list a chance to render.
      // Otherwise there's churn from multiple child lists mounting and un-mounting
      // their items.

      // Will this prevent rendering if the nested list doesn't realize the end?
      const childIdx = this._findFirstChildWithMore(
        newCellsAroundViewport.first,
        newCellsAroundViewport.last,
      );

      newCellsAroundViewport.last = childIdx ?? newCellsAroundViewport.last;
    }

    return newCellsAroundViewport;
  }

  _findFirstChildWithMore(first: number, last: number): number | null {
    for (let ii = first; ii <= last; ii++) {
      const cellKeyForIndex = this._indicesToKeys.get(ii);
      if (
        cellKeyForIndex != null &&
        this._nestedChildLists.anyInCell(cellKeyForIndex, childList =>
          childList.hasMore(),
        )
      ) {
        return ii;
      }
    }

    return null;
  }

  componentDidMount() {
    if (this._isNestedWithSameOrientation()) {
      this.context.registerAsNestedChild({
        ref: this,
        cellKey: this.context.cellKey,
      });
    }
  }

  componentWillUnmount() {
    if (this._isNestedWithSameOrientation()) {
      this.context.unregisterAsNestedChild({ref: this});
    }
    this._updateCellsToRenderBatcher.dispose({abort: true});
    this._viewabilityTuples.forEach(tuple => {
      tuple.viewabilityHelper.dispose();
    });
    this._fillRateHelper.deactivateAndFlush();
  }

  static getDerivedStateFromProps(newProps: Props, prevState: State): State {
    // first and last could be stale (e.g. if a new, shorter items props is passed in), so we make
    // sure we're rendering a reasonable range here.
    const itemCount = newProps.getItemCount(newProps.data);
    if (itemCount === prevState.renderMask.numCells()) {
      return prevState;
    }

    let maintainVisibleContentPositionAdjustment: ?number = null;
    const prevFirstVisibleItemKey = prevState.firstVisibleItemKey;
    const minIndexForVisible =
      newProps.maintainVisibleContentPosition?.minIndexForVisible ?? 0;
    const newFirstVisibleItemKey =
      newProps.getItemCount(newProps.data) > minIndexForVisible
        ? VirtualizedList._getItemKey(newProps, minIndexForVisible)
        : null;
    if (
      newProps.maintainVisibleContentPosition != null &&
      prevFirstVisibleItemKey != null &&
      newFirstVisibleItemKey != null
    ) {
      if (newFirstVisibleItemKey !== prevFirstVisibleItemKey) {
        // Fast path if items were added at the start of the list.
        const hint =
          itemCount - prevState.renderMask.numCells() + minIndexForVisible;
        const firstVisibleItemIndex = VirtualizedList._findItemIndexWithKey(
          newProps,
          prevFirstVisibleItemKey,
          hint,
        );
        maintainVisibleContentPositionAdjustment =
          firstVisibleItemIndex != null
            ? firstVisibleItemIndex - minIndexForVisible
            : null;
      } else {
        maintainVisibleContentPositionAdjustment = null;
      }
    }

    const constrainedCells = VirtualizedList._constrainToItemCount(
      maintainVisibleContentPositionAdjustment != null
        ? {
            first:
              prevState.cellsAroundViewport.first +
              maintainVisibleContentPositionAdjustment,
            last:
              prevState.cellsAroundViewport.last +
              maintainVisibleContentPositionAdjustment,
          }
        : prevState.cellsAroundViewport,
      newProps,
    );

    return {
      cellsAroundViewport: constrainedCells,
      renderMask: VirtualizedList._createRenderMask(newProps, constrainedCells),
      firstVisibleItemKey: newFirstVisibleItemKey,
      pendingScrollUpdateCount:
        maintainVisibleContentPositionAdjustment != null
          ? prevState.pendingScrollUpdateCount + 1
          : prevState.pendingScrollUpdateCount,
    };
  }

  _pushCells(
    cells: Array<Object>,
    stickyHeaderIndices: Array<number>,
    stickyIndicesFromProps: Set<number>,
    first: number,
    last: number,
    inversionStyle: ViewStyleProp,
  ) {
    const {
      CellRendererComponent,
      ItemSeparatorComponent,
      ListHeaderComponent,
      ListItemComponent,
      data,
      debug,
      getItem,
      getItemCount,
      getItemLayout,
      horizontal,
      renderItem,
    } = this.props;
    const stickyOffset = ListHeaderComponent ? 1 : 0;
    const end = getItemCount(data) - 1;
    let prevCellKey;
    last = Math.min(end, last);

    for (let ii = first; ii <= last; ii++) {
      const item = getItem(data, ii);
      const key = VirtualizedList._keyExtractor(item, ii, this.props);

      this._indicesToKeys.set(ii, key);
      if (stickyIndicesFromProps.has(ii + stickyOffset)) {
        stickyHeaderIndices.push(cells.length);
      }

      const shouldListenForLayout =
        getItemLayout == null || debug || this._fillRateHelper.enabled();

      cells.push(
        <CellRenderer
          CellRendererComponent={CellRendererComponent}
          ItemSeparatorComponent={ii < end ? ItemSeparatorComponent : undefined}
          ListItemComponent={ListItemComponent}
          cellKey={key}
          horizontal={horizontal}
          index={ii}
          inversionStyle={inversionStyle}
          item={item}
          key={key}
          prevCellKey={prevCellKey}
          onUpdateSeparators={this._onUpdateSeparators}
          onCellFocusCapture={this._onCellFocusCapture}
          onUnmount={this._onCellUnmount}
          ref={ref => {
            this._cellRefs[key] = ref;
          }}
          renderItem={renderItem}
          {...(shouldListenForLayout && {
            onCellLayout: this._onCellLayout,
          })}
        />,
      );
      prevCellKey = key;
    }
  }

  static _constrainToItemCount(
    cells: {first: number, last: number},
    props: Props,
  ): {first: number, last: number} {
    const itemCount = props.getItemCount(props.data);
    const lastPossibleCellIndex = itemCount - 1;

    // Constraining `last` may significantly shrink the window. Adjust `first`
    // to expand the window if the new `last` results in a new window smaller
    // than the number of cells rendered per batch.
    const maxToRenderPerBatch = maxToRenderPerBatchOrDefault(
      props.maxToRenderPerBatch,
    );
    const maxFirst = Math.max(0, lastPossibleCellIndex - maxToRenderPerBatch);

    return {
      first: clamp(0, cells.first, maxFirst),
      last: Math.min(lastPossibleCellIndex, cells.last),
    };
  }

  _onUpdateSeparators = (keys: Array<?string>, newProps: Object) => {
    keys.forEach(key => {
      const ref = key != null && this._cellRefs[key];
      ref && ref.updateSeparatorProps(newProps);
    });
  };

  _isNestedWithSameOrientation(): boolean {
    const nestedContext = this.context;
    return !!(
      nestedContext &&
      !!nestedContext.horizontal === horizontalOrDefault(this.props.horizontal)
    );
  }

  _getSpacerKey = (isVertical: boolean): string =>
    isVertical ? 'height' : 'width';

  static _keyExtractor(
    item: Item,
    index: number,
    props: {
      keyExtractor?: ?(item: Item, index: number) => string,
      ...
    },
  ): string {
    if (props.keyExtractor != null) {
      return props.keyExtractor(item, index);
    }

    const key = defaultKeyExtractor(item, index);
    if (key === String(index)) {
      _usedIndexForKey = true;
      if (item.type && item.type.displayName) {
        _keylessItemComponentName = item.type.displayName;
      }
    }
    return key;
  }

  render(): React.Node {
    this._checkProps(this.props);
    const {ListEmptyComponent, ListFooterComponent, ListHeaderComponent} =
      this.props;
    const {data, horizontal} = this.props;
    const inversionStyle = this.props.inverted
      ? horizontalOrDefault(this.props.horizontal)
        ? styles.horizontallyInverted
        : styles.verticallyInverted
      : null;
    const cells: Array<any | React.Node> = [];
    const stickyIndicesFromProps = new Set(this.props.stickyHeaderIndices);
    const stickyHeaderIndices = [];

    // 1. Add cell for ListHeaderComponent
    if (ListHeaderComponent) {
      if (stickyIndicesFromProps.has(0)) {
        stickyHeaderIndices.push(0);
      }
      const element = React.isValidElement(ListHeaderComponent) ? (
        ListHeaderComponent
      ) : (
        // $FlowFixMe[not-a-component]
        // $FlowFixMe[incompatible-type-arg]
        <ListHeaderComponent />
      );
      cells.push(
        <VirtualizedListCellContextProvider
          cellKey={this._getCellKey() + '-header'}
          key="$header">
          <View
            // We expect that header component will be a single native view so make it
            // not collapsable to avoid this view being flattened and make this assumption
            // no longer true.
            collapsable={false}
            onLayout={this._onLayoutHeader}
            style={StyleSheet.compose(
              inversionStyle,
              this.props.ListHeaderComponentStyle,
            )}>
            {
              // $FlowFixMe[incompatible-type] - Typing ReactNativeComponent revealed errors
              element
            }
          </View>
        </VirtualizedListCellContextProvider>,
      );
    }

    // 2a. Add a cell for ListEmptyComponent if applicable
    const itemCount = this.props.getItemCount(data);
    if (itemCount === 0 && ListEmptyComponent) {
      const element: ExactReactElement_DEPRECATED<any> = ((React.isValidElement(
        ListEmptyComponent,
      ) ? (
        ListEmptyComponent
      ) : (
        // $FlowFixMe[not-a-component]
        // $FlowFixMe[incompatible-type-arg]
        <ListEmptyComponent />
      )): any);
      cells.push(
        <VirtualizedListCellContextProvider
          cellKey={this._getCellKey() + '-empty'}
          key="$empty">
          {React.cloneElement(element, {
            onLayout: (event: LayoutEvent) => {
              this._onLayoutEmpty(event);
              // $FlowFixMe[prop-missing] React.Element internal inspection
              if (element.props.onLayout) {
                element.props.onLayout(event);
              }
            },
            // $FlowFixMe[prop-missing] React.Element internal inspection
            style: StyleSheet.compose(inversionStyle, element.props.style),
          })}
        </VirtualizedListCellContextProvider>,
      );
    }

    // 2b. Add cells and spacers for each item
    if (itemCount > 0) {
      _usedIndexForKey = false;
      _keylessItemComponentName = '';
      const spacerKey = this._getSpacerKey(!horizontal);

      const renderRegions = this.state.renderMask.enumerateRegions();
      const lastRegion = renderRegions[renderRegions.length - 1];
      const lastSpacer = lastRegion?.isSpacer ? lastRegion : null;

      for (const section of renderRegions) {
        if (section.isSpacer) {
          // Legacy behavior is to avoid spacers when virtualization is
          // disabled (including head spacers on initial render).
          if (this.props.disableVirtualization) {
            continue;
          }

          // Without getItemLayout, we limit our tail spacer to the _highestMeasuredFrameIndex to
          // prevent the user for hyperscrolling into un-measured area because otherwise content will
          // likely jump around as it renders in above the viewport.
          const isLastSpacer = section === lastSpacer;
          const constrainToMeasured = isLastSpacer && !this.props.getItemLayout;
          const last = constrainToMeasured
            ? clamp(
                section.first - 1,
                section.last,
                this._listMetrics.getHighestMeasuredCellIndex(),
              )
            : section.last;

          const firstMetrics = this._listMetrics.getCellMetricsApprox(
            section.first,
            this.props,
          );
          const lastMetrics = this._listMetrics.getCellMetricsApprox(
            last,
            this.props,
          );
          const spacerSize =
            lastMetrics.offset + lastMetrics.length - firstMetrics.offset;
          cells.push(
            <View
              key={`$spacer-${section.first}`}
              style={{[spacerKey]: spacerSize}}
            />,
          );
        } else {
          this._pushCells(
            cells,
            stickyHeaderIndices,
            stickyIndicesFromProps,
            section.first,
            section.last,
            inversionStyle,
          );
        }
      }

      if (!this._hasWarned.keys && _usedIndexForKey) {
        console.warn(
          'VirtualizedList: missing keys for items, make sure to specify a key or id property on each ' +
            'item or provide a custom keyExtractor.',
          _keylessItemComponentName,
        );
        this._hasWarned.keys = true;
      }
    }

    // 3. Add cell for ListFooterComponent
    if (ListFooterComponent) {
      const element = React.isValidElement(ListFooterComponent) ? (
        ListFooterComponent
      ) : (
        // $FlowFixMe[not-a-component]
        // $FlowFixMe[incompatible-type-arg]
        <ListFooterComponent />
      );
      cells.push(
        <VirtualizedListCellContextProvider
          cellKey={this._getFooterCellKey()}
          key="$footer">
          <View
            onLayout={this._onLayoutFooter}
            style={StyleSheet.compose(
              inversionStyle,
              this.props.ListFooterComponentStyle,
            )}>
            {
              // $FlowFixMe[incompatible-type] - Typing ReactNativeComponent revealed errors
              element
            }
          </View>
        </VirtualizedListCellContextProvider>,
      );
    }

    // 4. Render the ScrollView
    const scrollProps = {
      ...this.props,
      onContentSizeChange: this._onContentSizeChange,
      onLayout: this._onLayout,
      onScroll: this._onScroll,
      onScrollBeginDrag: this._onScrollBeginDrag,
      onScrollEndDrag: this._onScrollEndDrag,
      onMomentumScrollBegin: this._onMomentumScrollBegin,
      onMomentumScrollEnd: this._onMomentumScrollEnd,
      // iOS/macOS requires a non-zero scrollEventThrottle to fire more than a
      // single notification while scrolling. This will otherwise no-op.
      scrollEventThrottle: this.props.scrollEventThrottle ?? 0.0001,
      invertStickyHeaders:
        this.props.invertStickyHeaders !== undefined
          ? this.props.invertStickyHeaders
          : this.props.inverted,
      stickyHeaderIndices,
      style: inversionStyle
        ? [inversionStyle, this.props.style]
        : this.props.style,
      isInvertedVirtualizedList: this.props.inverted,
      maintainVisibleContentPosition:
        this.props.maintainVisibleContentPosition != null
          ? {
              ...this.props.maintainVisibleContentPosition,
              // Adjust index to account for ListHeaderComponent.
              minIndexForVisible:
                this.props.maintainVisibleContentPosition.minIndexForVisible +
                (this.props.ListHeaderComponent ? 1 : 0),
            }
          : undefined,
    };

    this._hasMore = this.state.cellsAroundViewport.last < itemCount - 1;

    const innerRet = (
      <VirtualizedListContextProvider
        value={{
          cellKey: null,
          getScrollMetrics: this._getScrollMetrics,
          horizontal: horizontalOrDefault(this.props.horizontal),
          getOutermostParentListRef: this._getOutermostParentListRef,
          registerAsNestedChild: this._registerAsNestedChild,
          unregisterAsNestedChild: this._unregisterAsNestedChild,
        }}>
        {React.cloneElement(
          (
            this.props.renderScrollComponent ||
            this._defaultRenderScrollComponent
          )(scrollProps),
          {
            ref: this._captureScrollRef,
          },
          cells,
        )}
      </VirtualizedListContextProvider>
    );
    let ret: React.Node = innerRet;
    if (__DEV__) {
      ret = (
        <ScrollView.Context.Consumer>
          {scrollContext => {
            if (
              scrollContext != null &&
              !scrollContext.horizontal ===
                !horizontalOrDefault(this.props.horizontal) &&
              !this._hasWarned.nesting &&
              this.context == null &&
              this.props.scrollEnabled !== false
            ) {
              console.error(
                'VirtualizedLists should never be nested inside plain ScrollViews with the same ' +
                  'orientation because it can break windowing and other functionality - use another ' +
                  'VirtualizedList-backed container instead.',
              );
              this._hasWarned.nesting = true;
            }
            return innerRet;
          }}
        </ScrollView.Context.Consumer>
      );
    }
    if (this.props.debug) {
      return (
        <View style={styles.debug}>
          {ret}
          {this._renderDebugOverlay()}
        </View>
      );
    } else {
      return ret;
    }
  }

  componentDidUpdate(prevProps: Props) {
    const {data, extraData, getItemLayout} = this.props;
    if (data !== prevProps.data || extraData !== prevProps.extraData) {
      // clear the viewableIndices cache to also trigger
      // the onViewableItemsChanged callback with the new data
      this._viewabilityTuples.forEach(tuple => {
        tuple.viewabilityHelper.resetViewableIndices();
      });
    }
    // The `this._hiPriInProgress` is guaranteeing a hiPri cell update will only happen
    // once per fiber update. The `_scheduleCellsToRenderUpdate` will set it to true
    // if a hiPri update needs to perform. If `componentDidUpdate` is triggered with
    // `this._hiPriInProgress=true`, means it's triggered by the hiPri update. The
    // `_scheduleCellsToRenderUpdate` will check this condition and not perform
    // another hiPri update.
    const hiPriInProgress = this._hiPriInProgress;
    this._scheduleCellsToRenderUpdate();
    // Make sure setting `this._hiPriInProgress` back to false after `componentDidUpdate`
    // is triggered with `this._hiPriInProgress = true`
    if (hiPriInProgress) {
      this._hiPriInProgress = false;
    }

    // We only call `onEndReached` after we render the last cell, but when
    // getItemLayout is present, we can scroll past the last rendered cell, and
    // never trigger a new layout or bounds change, so we need to check again
    // after rendering more cells.
    if (getItemLayout != null) {
      this._maybeCallOnEdgeReached();
    }
  }

  _cellRefs: {[string]: null | CellRenderer<any>} = {};
  _fillRateHelper: FillRateHelper;
  _listMetrics: ListMetricsAggregator = new ListMetricsAggregator();
  _footerLength = 0;
  // Used for preventing scrollToIndex from being called multiple times for initialScrollIndex
  _hasTriggeredInitialScrollToIndex = false;
  _hasInteracted = false;
  _hasMore = false;
  _hasWarned: {[string]: boolean} = {};
  _headerLength = 0;
  _hiPriInProgress: boolean = false; // flag to prevent infinite hiPri cell limit update
  _indicesToKeys: Map<number, string> = new Map();
  _lastFocusedCellKey: ?string = null;
  _nestedChildLists: ChildListCollection<VirtualizedList> =
    new ChildListCollection();
  _offsetFromParentVirtualizedList: number = 0;
  _pendingViewabilityUpdate: boolean = false;
  _prevParentOffset: number = 0;
  _scrollMetrics: {
    dOffset: number,
    dt: number,
    offset: number,
    timestamp: number,
    velocity: number,
    visibleLength: number,
    zoomScale: number,
  } = {
    dOffset: 0,
    dt: 10,
    offset: 0,
    timestamp: 0,
    velocity: 0,
    visibleLength: 0,
    zoomScale: 1,
  };
  _scrollRef: ?React.ElementRef<any> = null;
  _sentStartForContentLength = 0;
  _sentEndForContentLength = 0;
  _updateCellsToRenderBatcher: Batchinator;
  _viewabilityTuples: Array<ViewabilityHelperCallbackTuple> = [];

  /* $FlowFixMe[missing-local-annot] The type annotation(s) required by Flow's
   * LTI update could not be added via codemod */
  _captureScrollRef = ref => {
    this._scrollRef = ref;
  };

  _computeBlankness() {
    this._fillRateHelper.computeBlankness(
      this.props,
      this.state.cellsAroundViewport,
      this._scrollMetrics,
    );
  }

  /* $FlowFixMe[missing-local-annot] The type annotation(s) required by Flow's
   * LTI update could not be added via codemod */
  _defaultRenderScrollComponent = props => {
    const onRefresh = props.onRefresh;
    if (this._isNestedWithSameOrientation()) {
      // Prevent VirtualizedList._onContentSizeChange from being triggered by a bubbling onContentSizeChange event.
      // This could lead to internal inconsistencies within VirtualizedList.
      const {onContentSizeChange, ...otherProps} = props;
      return <View {...otherProps} />;
    } else if (onRefresh) {
      invariant(
        typeof props.refreshing === 'boolean',
        '`refreshing` prop must be set as a boolean in order to use `onRefresh`, but got `' +
          JSON.stringify(props.refreshing ?? 'undefined') +
          '`',
      );
      return (
        // $FlowFixMe[prop-missing] Invalid prop usage
        // $FlowFixMe[incompatible-use]
        <ScrollView
          {...props}
          refreshControl={
            props.refreshControl == null ? (
              <RefreshControl
                // $FlowFixMe[incompatible-type]
                refreshing={props.refreshing}
                onRefresh={onRefresh}
                progressViewOffset={props.progressViewOffset}
              />
            ) : (
              props.refreshControl
            )
          }
        />
      );
    } else {
      // $FlowFixMe[prop-missing] Invalid prop usage
      // $FlowFixMe[incompatible-use]
      return <ScrollView {...props} />;
    }
  };

  _onCellLayout = (
    e: LayoutEvent,
    cellKey: string,
    cellIndex: number,
  ): void => {
    const layoutHasChanged = this._listMetrics.notifyCellLayout({
      cellIndex,
      cellKey,
      layout: e.nativeEvent.layout,
      orientation: this._orientation(),
    });

    if (layoutHasChanged) {
      this._scheduleCellsToRenderUpdate();
    }

    this._triggerRemeasureForChildListsInCell(cellKey);
    this._computeBlankness();
    this._updateViewableItems(this.props, this.state.cellsAroundViewport);
  };

  _onCellFocusCapture = (cellKey: string) => {
    this._lastFocusedCellKey = cellKey;
    this._updateCellsToRender();
  };

  _onCellUnmount = (cellKey: string) => {
    delete this._cellRefs[cellKey];
    this._listMetrics.notifyCellUnmounted(cellKey);
  };

  _triggerRemeasureForChildListsInCell(cellKey: string): void {
    this._nestedChildLists.forEachInCell(cellKey, childList => {
      childList.measureLayoutRelativeToContainingList();
    });
  }

  measureLayoutRelativeToContainingList(): void {
    // TODO (T35574538): findNodeHandle sometimes crashes with "Unable to find
    // node on an unmounted component" during scrolling
    try {
      if (!this._scrollRef) {
        return;
      }
      // We are assuming that getOutermostParentListRef().getScrollRef()
      // is a non-null reference to a ScrollView
      this._scrollRef.measureLayout(
        this.context.getOutermostParentListRef().getScrollRef(),
        (x, y, width, height) => {
          this._offsetFromParentVirtualizedList = this._selectOffset({x, y});
          this._listMetrics.notifyListContentLayout({
            layout: {width, height},
            orientation: this._orientation(),
          });
          const scrollMetrics = this._convertParentScrollMetrics(
            this.context.getScrollMetrics(),
          );

          const metricsChanged =
            this._scrollMetrics.visibleLength !== scrollMetrics.visibleLength ||
            this._scrollMetrics.offset !== scrollMetrics.offset;

          if (metricsChanged) {
            this._scrollMetrics.visibleLength = scrollMetrics.visibleLength;
            this._scrollMetrics.offset = scrollMetrics.offset;

            // If metrics of the scrollView changed, then we triggered remeasure for child list
            // to ensure VirtualizedList has the right information.
            this._nestedChildLists.forEach(childList => {
              childList.measureLayoutRelativeToContainingList();
            });
          }
        },
        error => {
          console.warn(
            "VirtualizedList: Encountered an error while measuring a list's" +
              ' offset from its containing VirtualizedList.',
          );
        },
      );
    } catch (error) {
      console.warn(
        'measureLayoutRelativeToContainingList threw an error',
        error.stack,
      );
    }
  }

  _onLayout = (e: LayoutEvent) => {
    if (this._isNestedWithSameOrientation()) {
      // Need to adjust our scroll metrics to be relative to our containing
      // VirtualizedList before we can make claims about list item viewability
      this.measureLayoutRelativeToContainingList();
    } else {
      this._scrollMetrics.visibleLength = this._selectLength(
        e.nativeEvent.layout,
      );
    }
    this.props.onLayout && this.props.onLayout(e);
    this._scheduleCellsToRenderUpdate();
    this._maybeCallOnEdgeReached();
  };

  _onLayoutEmpty = (e: LayoutEvent) => {
    this.props.onLayout && this.props.onLayout(e);
  };

  _getFooterCellKey(): string {
    return this._getCellKey() + '-footer';
  }

  _onLayoutFooter = (e: LayoutEvent) => {
    this._triggerRemeasureForChildListsInCell(this._getFooterCellKey());
    this._footerLength = this._selectLength(e.nativeEvent.layout);
  };

  _onLayoutHeader = (e: LayoutEvent) => {
    this._headerLength = this._selectLength(e.nativeEvent.layout);
  };

  // $FlowFixMe[missing-local-annot]
  _renderDebugOverlay() {
    const normalize =
      this._scrollMetrics.visibleLength /
      (this._listMetrics.getContentLength() || 1);
    const framesInLayout = [];
    const itemCount = this.props.getItemCount(this.props.data);
    for (let ii = 0; ii < itemCount; ii++) {
      const frame = this._listMetrics.getCellMetricsApprox(ii, this.props);
      if (frame.isMounted) {
        framesInLayout.push(frame);
      }
    }
    const windowTop = this._listMetrics.getCellMetricsApprox(
      this.state.cellsAroundViewport.first,
      this.props,
    ).offset;
    const frameLast = this._listMetrics.getCellMetricsApprox(
      this.state.cellsAroundViewport.last,
      this.props,
    );
    const windowLen = frameLast.offset + frameLast.length - windowTop;
    const visTop = this._scrollMetrics.offset;
    const visLen = this._scrollMetrics.visibleLength;

    return (
      <View style={[styles.debugOverlayBase, styles.debugOverlay]}>
        {framesInLayout.map((f, ii) => (
          <View
            key={'f' + ii}
            style={[
              styles.debugOverlayBase,
              styles.debugOverlayFrame,
              {
                top: f.offset * normalize,
                height: f.length * normalize,
              },
            ]}
          />
        ))}
        <View
          style={[
            styles.debugOverlayBase,
            styles.debugOverlayFrameLast,
            {
              top: windowTop * normalize,
              height: windowLen * normalize,
            },
          ]}
        />
        <View
          style={[
            styles.debugOverlayBase,
            styles.debugOverlayFrameVis,
            {
              top: visTop * normalize,
              height: visLen * normalize,
            },
          ]}
        />
      </View>
    );
  }

  _selectLength(
    metrics: $ReadOnly<{
      height: number,
      width: number,
      ...
    }>,
  ): number {
    return !horizontalOrDefault(this.props.horizontal)
      ? metrics.height
      : metrics.width;
  }

  _selectOffset({x, y}: $ReadOnly<{x: number, y: number, ...}>): number {
    return this._orientation().horizontal ? x : y;
  }

  _orientation(): ListOrientation {
    return {
      horizontal: horizontalOrDefault(this.props.horizontal),
      rtl: I18nManager.isRTL,
    };
  }

  _maybeCallOnEdgeReached() {
    const {
      data,
      getItemCount,
      onStartReached,
      onStartReachedThreshold,
      onEndReached,
      onEndReachedThreshold,
    } = this.props;
    // Wait until we have real metrics
    if (
      !this._listMetrics.hasContentLength() ||
      this._scrollMetrics.visibleLength === 0
    ) {
      return;
    }

    // If we have any pending scroll updates it means that the scroll metrics
    // are out of date and we should not call any of the edge reached callbacks.
    if (this.state.pendingScrollUpdateCount > 0) {
      return;
    }

    const {visibleLength, offset} = this._scrollMetrics;
    let distanceFromStart = offset;
    let distanceFromEnd =
      this._listMetrics.getContentLength() - visibleLength - offset;

    // Especially when oERT is zero it's necessary to 'floor' very small distance values to be 0
    // since debouncing causes us to not fire this event for every single "pixel" we scroll and can thus
    // be at the edge of the list with a distance approximating 0 but not quite there.
    if (distanceFromStart < ON_EDGE_REACHED_EPSILON) {
      distanceFromStart = 0;
    }
    if (distanceFromEnd < ON_EDGE_REACHED_EPSILON) {
      distanceFromEnd = 0;
    }

    // TODO: T121172172 Look into why we're "defaulting" to a threshold of 2px
    // when oERT is not present (different from 2 viewports used elsewhere)
    const DEFAULT_THRESHOLD_PX = 2;

    const startThreshold =
      onStartReachedThreshold != null
        ? onStartReachedThreshold * visibleLength
        : DEFAULT_THRESHOLD_PX;
    const endThreshold =
      onEndReachedThreshold != null
        ? onEndReachedThreshold * visibleLength
        : DEFAULT_THRESHOLD_PX;
    const isWithinStartThreshold = distanceFromStart <= startThreshold;
    const isWithinEndThreshold = distanceFromEnd <= endThreshold;

    // First check if the user just scrolled within the end threshold
    // and call onEndReached only once for a given content length,
    // and only if onStartReached is not being executed
    if (
      onEndReached &&
      this.state.cellsAroundViewport.last === getItemCount(data) - 1 &&
      isWithinEndThreshold &&
      this._listMetrics.getContentLength() !== this._sentEndForContentLength
    ) {
      this._sentEndForContentLength = this._listMetrics.getContentLength();
      onEndReached({distanceFromEnd});
    }

    // Next check if the user just scrolled within the start threshold
    // and call onStartReached only once for a given content length,
    // and only if onEndReached is not being executed
    if (
      onStartReached != null &&
      this.state.cellsAroundViewport.first === 0 &&
      isWithinStartThreshold &&
      this._listMetrics.getContentLength() !== this._sentStartForContentLength
    ) {
      this._sentStartForContentLength = this._listMetrics.getContentLength();
      onStartReached({distanceFromStart});
    }

    // If the user scrolls away from the start or end and back again,
    // cause onStartReached or onEndReached to be triggered again
    if (!isWithinStartThreshold) {
      this._sentStartForContentLength = 0;
    }
    if (!isWithinEndThreshold) {
      this._sentEndForContentLength = 0;
    }
  }

  _onContentSizeChange = (width: number, height: number) => {
    this._listMetrics.notifyListContentLayout({
      layout: {width, height},
      orientation: this._orientation(),
    });

    this._maybeScrollToInitialScrollIndex(width, height);

    if (this.props.onContentSizeChange) {
      this.props.onContentSizeChange(width, height);
    }
    this._scheduleCellsToRenderUpdate();
    this._maybeCallOnEdgeReached();
  };

  /**
   * Scroll to a specified `initialScrollIndex` prop after the ScrollView
   * content has been laid out, if it is still valid. Only a single scroll is
   * triggered throughout the lifetime of the list.
   */
  _maybeScrollToInitialScrollIndex(
    contentWidth: number,
    contentHeight: number,
  ) {
    if (
      contentWidth > 0 &&
      contentHeight > 0 &&
      this.props.initialScrollIndex != null &&
      this.props.initialScrollIndex > 0 &&
      !this._hasTriggeredInitialScrollToIndex
    ) {
      if (this.props.contentOffset == null) {
        if (
          this.props.initialScrollIndex <
          this.props.getItemCount(this.props.data)
        ) {
          this.scrollToIndex({
            animated: false,
            index: nullthrows(this.props.initialScrollIndex),
          });
        } else {
          this.scrollToEnd({animated: false});
        }
      }
      this._hasTriggeredInitialScrollToIndex = true;
    }
  }

  /* Translates metrics from a scroll event in a parent VirtualizedList into
   * coordinates relative to the child list.
   */
  _convertParentScrollMetrics = (metrics: {
    visibleLength: number,
    offset: number,
    ...
  }): $FlowFixMe => {
    // Offset of the top of the nested list relative to the top of its parent's viewport
    const offset = metrics.offset - this._offsetFromParentVirtualizedList;
    // Child's visible length is the same as its parent's
    const visibleLength = metrics.visibleLength;
    const dOffset = offset - this._scrollMetrics.offset;
    const contentLength = this._listMetrics.getContentLength();

    return {
      visibleLength,
      contentLength,
      offset,
      dOffset,
    };
  };

  unstable_onScroll(e: Object) {
    this._onScroll(e);
  }

  _onScroll = (e: Object) => {
    this._nestedChildLists.forEach(childList => {
      childList._onScroll(e);
    });
    if (this.props.onScroll) {
      this.props.onScroll(e);
    }
    const timestamp = e.timeStamp;
    let visibleLength = this._selectLength(e.nativeEvent.layoutMeasurement);
    let contentLength = this._selectLength(e.nativeEvent.contentSize);
    let offset = this._offsetFromScrollEvent(e);
    let dOffset = offset - this._scrollMetrics.offset;

    if (this._isNestedWithSameOrientation()) {
      if (this._listMetrics.getContentLength() === 0) {
        // Ignore scroll events until onLayout has been called and we
        // know our offset from our offset from our parent
        return;
      }
      ({visibleLength, contentLength, offset, dOffset} =
        this._convertParentScrollMetrics({
          visibleLength,
          offset,
        }));
    }

    const dt = this._scrollMetrics.timestamp
      ? Math.max(1, timestamp - this._scrollMetrics.timestamp)
      : 1;
    const velocity = dOffset / dt;

    if (
      dt > 500 &&
      this._scrollMetrics.dt > 500 &&
      contentLength > 5 * visibleLength &&
      !this._hasWarned.perf
    ) {
      infoLog(
        'VirtualizedList: You have a large list that is slow to update - make sure your ' +
          'renderItem function renders components that follow React performance best practices ' +
          'like PureComponent, shouldComponentUpdate, etc.',
        {dt, prevDt: this._scrollMetrics.dt, contentLength},
      );
      this._hasWarned.perf = true;
    }

    // For invalid negative values (w/ RTL), set this to 1.
    const zoomScale = e.nativeEvent.zoomScale < 0 ? 1 : e.nativeEvent.zoomScale;
    this._scrollMetrics = {
      dt,
      dOffset,
      offset,
      timestamp,
      velocity,
      visibleLength,
      zoomScale,
    };
    if (this.state.pendingScrollUpdateCount > 0) {
      this.setState(state => ({
        pendingScrollUpdateCount: state.pendingScrollUpdateCount - 1,
      }));
    }
    this._updateViewableItems(this.props, this.state.cellsAroundViewport);
    if (!this.props) {
      return;
    }
    this._maybeCallOnEdgeReached();
    if (velocity !== 0) {
      this._fillRateHelper.activate();
    }
    this._computeBlankness();
    this._scheduleCellsToRenderUpdate();
  };

  _offsetFromScrollEvent(e: ScrollEvent): number {
    const {contentOffset, contentSize, layoutMeasurement} = e.nativeEvent;
    const {horizontal, rtl} = this._orientation();
    if (horizontal && rtl) {
      return (
        this._selectLength(contentSize) -
        (this._selectOffset(contentOffset) +
          this._selectLength(layoutMeasurement))
      );
    } else {
      return this._selectOffset(contentOffset);
    }
  }

  _scheduleCellsToRenderUpdate() {
    // Only trigger high-priority updates if we've actually rendered cells,
    // and with that size estimate, accurately compute how many cells we should render.
    // Otherwise, it would just render as many cells as it can (of zero dimension),
    // each time through attempting to render more (limited by maxToRenderPerBatch),
    // starving the renderer from actually laying out the objects and computing _averageCellLength.
    // If this is triggered in an `componentDidUpdate` followed by a hiPri cellToRenderUpdate
    // We shouldn't do another hipri cellToRenderUpdate
    if (
      (this._listMetrics.getAverageCellLength() > 0 ||
        this.props.getItemLayout != null) &&
      this._shouldRenderWithPriority() &&
      !this._hiPriInProgress
    ) {
      this._hiPriInProgress = true;
      // Don't worry about interactions when scrolling quickly; focus on filling content as fast
      // as possible.
      this._updateCellsToRenderBatcher.dispose({abort: true});
      this._updateCellsToRender();
      return;
    } else {
      this._updateCellsToRenderBatcher.schedule();
    }
  }

  _shouldRenderWithPriority(): boolean {
    const {first, last} = this.state.cellsAroundViewport;
    const {offset, visibleLength, velocity} = this._scrollMetrics;
    const itemCount = this.props.getItemCount(this.props.data);
    let hiPri = false;
    const onStartReachedThreshold = onStartReachedThresholdOrDefault(
      this.props.onStartReachedThreshold,
    );
    const onEndReachedThreshold = onEndReachedThresholdOrDefault(
      this.props.onEndReachedThreshold,
    );
    // Mark as high priority if we're close to the start of the first item
    // But only if there are items before the first rendered item
    if (first > 0) {
      const distTop =
        offset -
        this._listMetrics.getCellMetricsApprox(first, this.props).offset;
      hiPri =
        distTop < 0 ||
        (velocity < -2 &&
          distTop <
            getScrollingThreshold(onStartReachedThreshold, visibleLength));
    }
    // Mark as high priority if we're close to the end of the last item
    // But only if there are items after the last rendered item
    if (!hiPri && last >= 0 && last < itemCount - 1) {
      const distBottom =
        this._listMetrics.getCellMetricsApprox(last, this.props).offset -
        (offset + visibleLength);
      hiPri =
        distBottom < 0 ||
        (velocity > 2 &&
          distBottom <
            getScrollingThreshold(onEndReachedThreshold, visibleLength));
    }

    return hiPri;
  }

  unstable_onScrollBeginDrag(e: ScrollEvent) {
    this._onScrollBeginDrag(e);
  }

  _onScrollBeginDrag = (e: ScrollEvent): void => {
    this._nestedChildLists.forEach(childList => {
      childList._onScrollBeginDrag(e);
    });
    this._viewabilityTuples.forEach(tuple => {
      tuple.viewabilityHelper.recordInteraction();
    });
    this._hasInteracted = true;
    this.props.onScrollBeginDrag && this.props.onScrollBeginDrag(e);
  };

  unstable_onScrollEndDrag(e: ScrollEvent) {
    this._onScrollEndDrag(e);
  }

  _onScrollEndDrag = (e: ScrollEvent): void => {
    this._nestedChildLists.forEach(childList => {
      childList._onScrollEndDrag(e);
    });
    const {velocity} = e.nativeEvent;
    if (velocity) {
      this._scrollMetrics.velocity = this._selectOffset(velocity);
    }
    this._computeBlankness();
    this.props.onScrollEndDrag && this.props.onScrollEndDrag(e);
  };

  unstable_onMomentumScrollBegin(e: ScrollEvent) {
    this._onMomentumScrollBegin(e);
  }

  _onMomentumScrollBegin = (e: ScrollEvent): void => {
    this._nestedChildLists.forEach(childList => {
      childList._onMomentumScrollBegin(e);
    });
    this.props.onMomentumScrollBegin && this.props.onMomentumScrollBegin(e);
  };

  unstable_onMomentumScrollEnd(e: ScrollEvent) {
    this._onMomentumScrollEnd(e);
  }

  _onMomentumScrollEnd = (e: ScrollEvent): void => {
    this._nestedChildLists.forEach(childList => {
      childList._onMomentumScrollEnd(e);
    });
    this._scrollMetrics.velocity = 0;
    this._computeBlankness();
    this.props.onMomentumScrollEnd && this.props.onMomentumScrollEnd(e);
  };

  _updateCellsToRender = () => {
    this._updateViewableItems(this.props, this.state.cellsAroundViewport);

    this.setState((state, props) => {
      const cellsAroundViewport = this._adjustCellsAroundViewport(
        props,
        state.cellsAroundViewport,
        state.pendingScrollUpdateCount,
      );
      const renderMask = VirtualizedList._createRenderMask(
        props,
        cellsAroundViewport,
        this._getNonViewportRenderRegions(props),
      );

      if (
        cellsAroundViewport.first === state.cellsAroundViewport.first &&
        cellsAroundViewport.last === state.cellsAroundViewport.last &&
        renderMask.equals(state.renderMask)
      ) {
        return null;
      }

      return {cellsAroundViewport, renderMask};
    });
  };

  _createViewToken = (
    index: number,
    isViewable: boolean,
    props: CellMetricProps,
    // $FlowFixMe[missing-local-annot]
  ) => {
    const {data, getItem} = props;
    const item = getItem(data, index);
    return {
      index,
      item,
      key: VirtualizedList._keyExtractor(item, index, props),
      isViewable,
    };
  };

  __getListMetrics(): ListMetricsAggregator {
    return this._listMetrics;
  }

  _getNonViewportRenderRegions = (
    props: CellMetricProps,
  ): $ReadOnlyArray<{
    first: number,
    last: number,
  }> => {
    // Keep a viewport's worth of content around the last focused cell to allow
    // random navigation around it without any blanking. E.g. tabbing from one
    // focused item out of viewport to another.
    if (
      !(this._lastFocusedCellKey && this._cellRefs[this._lastFocusedCellKey])
    ) {
      return [];
    }

    const lastFocusedCellRenderer = this._cellRefs[this._lastFocusedCellKey];
    const focusedCellIndex = lastFocusedCellRenderer.props.index;
    const itemCount = props.getItemCount(props.data);

    // The last cell we rendered may be at a new index. Bail if we don't know
    // where it is.
    if (
      focusedCellIndex >= itemCount ||
      VirtualizedList._getItemKey(props, focusedCellIndex) !==
        this._lastFocusedCellKey
    ) {
      return [];
    }

    let first = focusedCellIndex;
    let heightOfCellsBeforeFocused = 0;
    for (
      let i = first - 1;
      i >= 0 && heightOfCellsBeforeFocused < this._scrollMetrics.visibleLength;
      i--
    ) {
      first--;
      heightOfCellsBeforeFocused += this._listMetrics.getCellMetricsApprox(
        i,
        props,
      ).length;
    }

    let last = focusedCellIndex;
    let heightOfCellsAfterFocused = 0;
    for (
      let i = last + 1;
      i < itemCount &&
      heightOfCellsAfterFocused < this._scrollMetrics.visibleLength;
      i++
    ) {
      last++;
      heightOfCellsAfterFocused += this._listMetrics.getCellMetricsApprox(
        i,
        props,
      ).length;
    }

    return [{first, last}];
  };

  _updateViewableItems(
    props: CellMetricProps,
    cellsAroundViewport: {first: number, last: number},
  ) {
    // If we have any pending scroll updates it means that the scroll metrics
    // are out of date and we should not call any of the visibility callbacks.
    if (this.state.pendingScrollUpdateCount > 0) {
      return;
    }
    this._viewabilityTuples.forEach(tuple => {
      tuple.viewabilityHelper.onUpdate(
        props,
        this._scrollMetrics.offset,
        this._scrollMetrics.visibleLength,
        this._listMetrics,
        this._createViewToken,
        tuple.onViewableItemsChanged,
        cellsAroundViewport,
      );
    });
  }
}

const styles = StyleSheet.create({
  verticallyInverted:
    Platform.OS === 'android'
      ? {transform: [{scale: -1}]}
      : {transform: [{scaleY: -1}]},
  horizontallyInverted: {
    transform: [{scaleX: -1}],
  },
  debug: {
    flex: 1,
  },
  debugOverlayBase: {
    position: 'absolute',
    top: 0,
    right: 0,
  },
  debugOverlay: {
    bottom: 0,
    width: 20,
    borderColor: 'blue',
    borderWidth: 1,
  },
  debugOverlayFrame: {
    left: 0,
    backgroundColor: 'orange',
  },
  debugOverlayFrameLast: {
    left: 0,
    borderColor: 'green',
    borderWidth: 2,
  },
  debugOverlayFrameVis: {
    left: 0,
    borderColor: 'red',
    borderWidth: 2,
  },
});

module.exports = VirtualizedList;
