diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index e1654b681c24..6b0c0aaa64b2 100644 --- a/packages/react-native/ReactNativeApi.d.ts +++ b/packages/react-native/ReactNativeApi.d.ts @@ -4,7 +4,7 @@ * This source code is licensed under the MIT license found in the * LICENSE file in the root directory of this source tree. * - * @generated SignedSource<> + * @generated SignedSource<<263d6fb9708d4d343663fecd91a48a8a>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -1776,6 +1776,7 @@ declare function configureNext( declare type ContentAvailable = 1 | null | void declare type Context = { readonly cellKey: string | undefined + readonly getCellVisibilityByKey?: (cellKey: string) => boolean | undefined readonly horizontal: boolean | undefined readonly getOutermostParentListRef: () => VirtualizedList_default readonly getScrollMetrics: () => { @@ -1790,6 +1791,7 @@ declare type Context = { } readonly registerAsNestedChild: ($$PARAM_0$$: { cellKey: string + horizontal?: boolean ref: VirtualizedList_default }) => void readonly unregisterAsNestedChild: ($$PARAM_0$$: { @@ -5490,6 +5492,7 @@ declare class ViewabilityHelper_default { first: number last: number }, + suppressViewableItems?: boolean, ): void recordInteraction(): void resetViewableIndices(): void diff --git a/packages/virtualized-lists/Lists/ViewabilityHelper.js b/packages/virtualized-lists/Lists/ViewabilityHelper.js index 8157efa4dfa5..17e87b78f928 100644 --- a/packages/virtualized-lists/Lists/ViewabilityHelper.js +++ b/packages/virtualized-lists/Lists/ViewabilityHelper.js @@ -82,7 +82,9 @@ export type ViewabilityConfig = Readonly<{ class ViewabilityHelper { _config: ViewabilityConfig; _hasInteracted: boolean = false; - _timers: Set = new Set(); + _pendingSuppressedUpdate: boolean = false; + _timers: Set = new Set(); + _updateGeneration: number = 0; _viewableIndices: Array = []; _viewableItems: Map = new Map(); @@ -96,9 +98,6 @@ class ViewabilityHelper { * Cleanup, e.g. on unmount. Clears any pending timers. */ dispose() { - /* $FlowFixMe[incompatible-type] (>=0.63.0 site=react_native_fb) This - * comment suppresses an error found when Flow v0.63 was deployed. To see - * the error delete this comment and run Flow. */ this._timers.forEach(clearTimeout); } @@ -197,17 +196,31 @@ class ViewabilityHelper { last: number, ... }, + // Suppression bypasses the normal early returns so an empty result clears + // items reported before an ancestor moved off screen. + suppressViewableItems?: boolean, ): void { + const updateGeneration = this._updateGeneration + 1; const itemCount = props.getItemCount(props.data); if ( - (this._config.waitForInteraction && !this._hasInteracted) || - itemCount === 0 || - !listMetrics.getCellMetrics(0, props) + suppressViewableItems !== true && + this._config.waitForInteraction && + !this._hasInteracted ) { + this._updateGeneration = updateGeneration; + this._pendingSuppressedUpdate = false; + return; + } + if ( + suppressViewableItems !== true && + (itemCount === 0 || !listMetrics.getCellMetrics(0, props)) + ) { + this._updateGeneration = updateGeneration; + this._pendingSuppressedUpdate = false; return; } let viewableIndices: Array = []; - if (itemCount) { + if (itemCount && suppressViewableItems !== true) { viewableIndices = this.computeViewableItems( props, scrollOffset, @@ -218,23 +231,25 @@ class ViewabilityHelper { } if ( this._viewableIndices.length === viewableIndices.length && - this._viewableIndices.every((v, ii) => v === viewableIndices[ii]) + this._viewableIndices.every((v, ii) => v === viewableIndices[ii]) && + (suppressViewableItems !== true || + this._viewableItems.size === 0 || + this._pendingSuppressedUpdate) ) { // We might get a lot of scroll events where visibility doesn't change and we don't want to do // extra work in those cases. return; } this._viewableIndices = viewableIndices; + this._updateGeneration = updateGeneration; if (this._config.minimumViewTime) { + this._pendingSuppressedUpdate = suppressViewableItems === true; const handle: TimeoutID = setTimeout(() => { - /* $FlowFixMe[incompatible-type] (>=0.63.0 site=react_native_fb) This - * comment suppresses an error found when Flow v0.63 was deployed. To - * see the error delete this comment and run Flow. */ this._timers.delete(handle); - // `onUpdate` replaces the array whenever the visible set changes. - if (this._viewableIndices !== viewableIndices) { + if (this._updateGeneration !== updateGeneration) { return; } + this._pendingSuppressedUpdate = false; this._onUpdateSync( props, viewableIndices, @@ -242,11 +257,9 @@ class ViewabilityHelper { createViewToken, ); }, this._config.minimumViewTime); - /* $FlowFixMe[incompatible-type] (>=0.63.0 site=react_native_fb) This - * comment suppresses an error found when Flow v0.63 was deployed. To see - * the error delete this comment and run Flow. */ this._timers.add(handle); } else { + this._pendingSuppressedUpdate = false; this._onUpdateSync( props, viewableIndices, @@ -261,6 +274,8 @@ class ViewabilityHelper { */ resetViewableIndices() { this._viewableIndices = []; + this._pendingSuppressedUpdate = false; + this._updateGeneration++; } /** diff --git a/packages/virtualized-lists/Lists/VirtualizedList.js b/packages/virtualized-lists/Lists/VirtualizedList.js index 413d14d53d29..1fbee2ea60b8 100644 --- a/packages/virtualized-lists/Lists/VirtualizedList.js +++ b/packages/virtualized-lists/Lists/VirtualizedList.js @@ -83,6 +83,25 @@ type ViewabilityHelperCallbackTuple = { ... }; +type CrossOrientationChildRegistration = { + cellKey: string, + lastSuppressed: ?boolean, +}; + +type NestedChildRegistration = { + cellKey: string, + horizontal?: boolean, + ref: VirtualizedList, +}; + +type ParentRegistration = { + cellKey: string, + horizontal: boolean, + register: (childList: NestedChildRegistration) => void, + sameOrientation: boolean, + unregister: (childList: {ref: VirtualizedList}) => void, +}; + type State = { renderMask: CellRenderMask, cellsAroundViewport: {first: number, last: number}, @@ -360,20 +379,118 @@ class VirtualizedList extends StateSafePureComponent< } }; - _registerAsNestedChild = (childList: { - cellKey: string, - ref: VirtualizedList, - }): void => { - this._nestedChildLists.add(childList.ref, childList.cellKey); - if (this._hasInteracted) { - childList.ref.recordInteraction(); + _registerAsNestedChild = (childList: NestedChildRegistration): void => { + if ( + childList.horizontal == null || + childList.horizontal === horizontalOrDefault(this.props.horizontal) + ) { + this._nestedChildLists.add(childList.ref, childList.cellKey); + if (this._hasInteracted) { + childList.ref.recordInteraction(); + } + return; + } + + let childLists = this._crossOrientationChildLists; + if (childLists == null) { + childLists = new Map(); + this._crossOrientationChildLists = childLists; } + childLists.set(childList.ref, { + cellKey: childList.cellKey, + lastSuppressed: null, + }); }; _unregisterAsNestedChild = (childList: {ref: VirtualizedList}): void => { - this._nestedChildLists.remove(childList.ref); + const childLists = this._crossOrientationChildLists; + if (childLists != null && childLists.delete(childList.ref)) { + if (childLists.size === 0) { + this._crossOrientationChildLists = null; + } + } else { + this._nestedChildLists.remove(childList.ref); + } }; + _getCellVisibilityByKey = ( + cellKey: string, + pendingScrollUpdateCount: number = this.state.pendingScrollUpdateCount, + ): ?boolean => { + if (this._shouldSuppressViewableItems() || pendingScrollUpdateCount > 0) { + return false; + } + + const cell = this._cellRefs[cellKey]; + if (cell == null) { + return null; + } + + const index = cell.props.index; + const itemCount = this.props.getItemCount(this.props.data); + if ( + index < 0 || + index >= itemCount || + VirtualizedList._getItemKey(this.props, index) !== cellKey + ) { + return false; + } + + const cellMetrics = this._listMetrics.getCellMetrics(index, this.props); + if (cellMetrics == null) { + return false; + } + + const {crossAxisLength, offset, visibleLength} = this._getScrollMetrics(); + if (crossAxisLength <= 0 || visibleLength <= 0) { + return false; + } + const top = cellMetrics.offset - offset; + const bottom = top + cellMetrics.length; + return top < visibleLength && bottom > 0; + }; + + _onParentViewportChanged = (suppressViewableItems: boolean): void => { + this._updateViewableItems( + this.props, + this.state.cellsAroundViewport, + suppressViewableItems, + ); + this._nestedChildLists.forEach(child => { + child._onParentViewportChanged(suppressViewableItems); + }); + this._notifyCrossOrientationChildren(suppressViewableItems); + }; + + _notifyCrossOrientationChildren( + ancestorSuppressed: boolean = false, + pendingScrollUpdateCount: number = this.state.pendingScrollUpdateCount, + ): void { + this._crossOrientationChildLists?.forEach((registration, child) => { + const suppressViewableItems = + ancestorSuppressed || + this._getCellVisibilityByKey( + registration.cellKey, + pendingScrollUpdateCount, + ) === false; + if (registration.lastSuppressed !== suppressViewableItems) { + child._onParentViewportChanged(suppressViewableItems); + registration.lastSuppressed = suppressViewableItems; + } + }); + } + + _shouldSuppressViewableItems(): boolean { + const context = this.context; + if (context?.cellKey == null) { + return false; + } + if (!!context.horizontal === horizontalOrDefault(this.props.horizontal)) { + return false; + } + return context.getCellVisibilityByKey?.(context.cellKey) === false; + } + state: State; constructor(props: VirtualizedListProps) { @@ -696,18 +813,11 @@ class VirtualizedList extends StateSafePureComponent< } componentDidMount() { - if (this._isNestedWithSameOrientation()) { - this.context.registerAsNestedChild({ - ref: this, - cellKey: this.context.cellKey, - }); - } + this._reconcileParentRegistration(); } componentWillUnmount() { - if (this._isNestedWithSameOrientation()) { - this.context.unregisterAsNestedChild({ref: this}); - } + this._unregisterFromParent(); clearTimeout(this._updateCellsToRenderTimeoutID); this._viewabilityTuples.forEach(tuple => { tuple.viewabilityHelper.dispose(); @@ -884,6 +994,52 @@ class VirtualizedList extends StateSafePureComponent< ); } + _reconcileParentRegistration(): void { + const context = this.context; + const cellKey = context?.cellKey; + if (context == null || cellKey == null) { + this._unregisterFromParent(); + return; + } + + const horizontal = horizontalOrDefault(this.props.horizontal); + const sameOrientation = !!context.horizontal === horizontal; + if (!sameOrientation && context.getCellVisibilityByKey == null) { + this._unregisterFromParent(); + return; + } + + const registration = this._parentRegistration; + if ( + registration != null && + registration.cellKey === cellKey && + registration.horizontal === horizontal && + registration.sameOrientation === sameOrientation && + registration.register === context.registerAsNestedChild && + registration.unregister === context.unregisterAsNestedChild + ) { + return; + } + + this._unregisterFromParent(); + context.registerAsNestedChild({ref: this, cellKey, horizontal}); + this._parentRegistration = { + cellKey, + horizontal, + register: context.registerAsNestedChild, + sameOrientation, + unregister: context.unregisterAsNestedChild, + }; + } + + _unregisterFromParent(): void { + const registration = this._parentRegistration; + if (registration != null) { + this._parentRegistration = null; + registration.unregister({ref: this}); + } + } + _getSpacerKey = (isVertical: boolean): string => isVertical ? 'height' : 'width'; @@ -1146,6 +1302,7 @@ class VirtualizedList extends StateSafePureComponent< getOutermostParentListRef: this._getOutermostParentListRef, registerAsNestedChild: this._registerAsNestedChild, unregisterAsNestedChild: this._unregisterAsNestedChild, + getCellVisibilityByKey: this._getCellVisibilityByKey, }}> {cloneElement( ( @@ -1200,6 +1357,7 @@ class VirtualizedList extends StateSafePureComponent< } componentDidUpdate(prevProps: VirtualizedListProps) { + this._reconcileParentRegistration(); const {data, extraData, getItemLayout} = this.props; if (data !== prevProps.data || extraData !== prevProps.extraData) { // clear the viewableIndices cache to also trigger @@ -1246,6 +1404,11 @@ class VirtualizedList extends StateSafePureComponent< _lastFocusedCellKey: ?string = null; _nestedChildLists: ChildListCollection = new ChildListCollection(); + _crossOrientationChildLists: ?Map< + VirtualizedList, + CrossOrientationChildRegistration, + > = null; + _parentRegistration: ?ParentRegistration = null; _offsetFromParentVirtualizedList: number = 0; _pendingViewabilityUpdate: boolean = false; _prevParentOffset: number = 0; @@ -1347,6 +1510,7 @@ class VirtualizedList extends StateSafePureComponent< this._triggerRemeasureForChildListsInCell(cellKey); this._computeBlankness(); this._updateViewableItems(this.props, this.state.cellsAroundViewport); + this._notifyCrossOrientationChildren(); }; _onCellFocusCapture = (cellKey: string) => { @@ -1407,6 +1571,7 @@ class VirtualizedList extends StateSafePureComponent< this._nestedChildLists.forEach(childList => { childList.measureLayoutRelativeToContainingList(); }); + this._notifyCrossOrientationChildren(); } }, error => { @@ -1436,6 +1601,8 @@ class VirtualizedList extends StateSafePureComponent< this._scrollMetrics.visibleLength = this._selectLength( e.nativeEvent.layout, ); + this._updateViewableItems(this.props, this.state.cellsAroundViewport); + this._notifyCrossOrientationChildren(); } this.props.onLayout && this.props.onLayout(e); this._scheduleCellsToRenderUpdate(); @@ -1788,6 +1955,7 @@ class VirtualizedList extends StateSafePureComponent< this.setState<'pendingScrollUpdateCount'>({pendingScrollUpdateCount: 0}); } this._updateViewableItems(this.props, this.state.cellsAroundViewport); + this._notifyCrossOrientationChildren(false, 0); if (!this.props) { return; } @@ -1942,6 +2110,7 @@ class VirtualizedList extends StateSafePureComponent< _updateCellsToRender = () => { this._updateViewableItems(this.props, this.state.cellsAroundViewport); + this._notifyCrossOrientationChildren(); this.setState<'cellsAroundViewport' | 'renderMask'>((state, props) => { const cellsAroundViewport = this._adjustCellsAroundViewport( @@ -2051,16 +2220,22 @@ class VirtualizedList extends StateSafePureComponent< _updateViewableItems( props: CellMetricProps, cellsAroundViewport: {first: number, last: number}, + suppressViewableItems?: boolean, ) { // 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) { + if ( + suppressViewableItems !== true && + this.state.pendingScrollUpdateCount > 0 + ) { return; } const visibleLength = this._scrollMetrics.crossAxisLength > 0 ? this._scrollMetrics.visibleLength : 0; + const shouldSuppressViewableItems = + suppressViewableItems ?? this._shouldSuppressViewableItems(); this._viewabilityTuples.forEach(tuple => { tuple.viewabilityHelper.onUpdate( props, @@ -2070,6 +2245,7 @@ class VirtualizedList extends StateSafePureComponent< this._createViewToken, tuple.onViewableItemsChanged, cellsAroundViewport, + shouldSuppressViewableItems, ); }); } diff --git a/packages/virtualized-lists/Lists/VirtualizedListContext.js b/packages/virtualized-lists/Lists/VirtualizedListContext.js index 77655687d3ce..6aa40c44193e 100644 --- a/packages/virtualized-lists/Lists/VirtualizedListContext.js +++ b/packages/virtualized-lists/Lists/VirtualizedListContext.js @@ -27,8 +27,14 @@ type Context = Readonly<{ }, horizontal: ?boolean, getOutermostParentListRef: () => VirtualizedList, - registerAsNestedChild: ({cellKey: string, ref: VirtualizedList}) => void, + registerAsNestedChild: ({ + cellKey: string, + horizontal?: boolean, + ref: VirtualizedList, + }) => void, unregisterAsNestedChild: ({ref: VirtualizedList}) => void, + getCellVisibilityByKey?: (cellKey: string) => ?boolean, + ... }>; export const VirtualizedListContext: React.Context = @@ -71,6 +77,7 @@ export function VirtualizedListContextProvider({ getOutermostParentListRef: value.getOutermostParentListRef, registerAsNestedChild: value.registerAsNestedChild, unregisterAsNestedChild: value.unregisterAsNestedChild, + getCellVisibilityByKey: value.getCellVisibilityByKey, }), [ value.getScrollMetrics, @@ -78,6 +85,7 @@ export function VirtualizedListContextProvider({ value.getOutermostParentListRef, value.registerAsNestedChild, value.unregisterAsNestedChild, + value.getCellVisibilityByKey, ], ); return ( diff --git a/packages/virtualized-lists/Lists/__tests__/ViewabilityHelper-test.js b/packages/virtualized-lists/Lists/__tests__/ViewabilityHelper-test.js index 257757048d6e..f279662508c6 100644 --- a/packages/virtualized-lists/Lists/__tests__/ViewabilityHelper-test.js +++ b/packages/virtualized-lists/Lists/__tests__/ViewabilityHelper-test.js @@ -10,6 +10,7 @@ import type {CellMetricProps} from '../ListMetricsAggregator'; +import ListMetricsAggregator from '../ListMetricsAggregator'; import ViewabilityHelper from '../ViewabilityHelper'; let rowFrames: ?{ @@ -34,6 +35,26 @@ function createViewToken(index: number, isViewable: boolean): $FlowFixMe { return {key: data[index].key, isViewable}; } +function createMeasuredListMetrics(): ListMetricsAggregator { + if (rowFrames == null) { + throw new Error('Expected `rowFrames` to have been initialized.'); + } + const listMetrics = new ListMetricsAggregator(); + data.forEach((item, index) => { + const frame = rowFrames?.[item.key]; + if (frame == null) { + throw new Error(`Expected metrics for ${item.key}.`); + } + listMetrics.notifyCellLayout({ + cellIndex: index, + cellKey: item.key, + layout: {height: frame.height, width: 100, x: 0, y: frame.y}, + orientation: {horizontal: false, rtl: false}, + }); + }); + return listMetrics; +} + describe('computeViewableItems', function () { it('returns all 4 entirely visible rows as viewable', function () { const helper = new ViewabilityHelper({ @@ -199,6 +220,264 @@ describe('computeViewableItems', function () { }); describe('onUpdate', function () { + it.each([ + ['view area coverage', {viewAreaCoveragePercentThreshold: 0}], + ['item visibility', {itemVisiblePercentThreshold: 0}], + ])( + 'suppresses previously published items with %s even without current cell metrics', + (_name, config) => { + const helper = new ViewabilityHelper(config); + rowFrames = {a: {y: 0, height: 50}}; + data = [{key: 'a'}]; + const measuredProps: CellMetricProps = { + ...props, + data, + getItem: (items, index) => items[index], + }; + const onViewableItemsChanged = jest.fn(); + helper.onUpdate( + measuredProps, + 0, + 50, + createMeasuredListMetrics(), + createViewToken, + onViewableItemsChanged, + ); + + helper.resetViewableIndices(); + helper.onUpdate( + measuredProps, + 0, + 50, + new ListMetricsAggregator(), + createViewToken, + onViewableItemsChanged, + undefined, + true, + ); + + expect(onViewableItemsChanged).toHaveBeenLastCalledWith({ + changed: [{isViewable: false, key: 'a'}], + viewabilityConfig: config, + viewableItems: [], + }); + }, + ); + + it('invalidates pending minimum-view-time updates when suppressed', function () { + const helper = new ViewabilityHelper({ + minimumViewTime: 350, + viewAreaCoveragePercentThreshold: 0, + }); + rowFrames = {a: {y: 0, height: 50}}; + data = [{key: 'a'}]; + const measuredProps: CellMetricProps = { + ...props, + data, + getItem: (items, index) => items[index], + }; + const onViewableItemsChanged = jest.fn(); + helper.onUpdate( + measuredProps, + 0, + 50, + createMeasuredListMetrics(), + createViewToken, + onViewableItemsChanged, + ); + helper.onUpdate( + measuredProps, + 0, + 50, + new ListMetricsAggregator(), + createViewToken, + onViewableItemsChanged, + undefined, + true, + ); + + jest.runAllTimers(); + + expect(onViewableItemsChanged).not.toHaveBeenCalled(); + }); + + it('invalidates pending minimum-view-time updates without metrics', function () { + const helper = new ViewabilityHelper({ + minimumViewTime: 350, + viewAreaCoveragePercentThreshold: 0, + }); + rowFrames = {a: {y: 0, height: 50}}; + data = [{key: 'a'}]; + const measuredProps: CellMetricProps = { + ...props, + data, + getItem: (items, index) => items[index], + }; + const onViewableItemsChanged = jest.fn(); + helper.onUpdate( + measuredProps, + 0, + 50, + createMeasuredListMetrics(), + createViewToken, + onViewableItemsChanged, + ); + helper.onUpdate( + measuredProps, + 0, + 50, + new ListMetricsAggregator(), + createViewToken, + onViewableItemsChanged, + ); + + jest.runAllTimers(); + + expect(onViewableItemsChanged).not.toHaveBeenCalled(); + }); + + it('clears published items when suppression overrides interaction', function () { + const config = { + waitForInteraction: false, + viewAreaCoveragePercentThreshold: 0, + }; + const helper = new ViewabilityHelper(config); + rowFrames = {a: {y: 0, height: 50}}; + data = [{key: 'a'}]; + const measuredProps: CellMetricProps = { + ...props, + data, + getItem: (items, index) => items[index], + }; + const onViewableItemsChanged = jest.fn(); + helper.onUpdate( + measuredProps, + 0, + 50, + createMeasuredListMetrics(), + createViewToken, + onViewableItemsChanged, + ); + config.waitForInteraction = true; + + helper.onUpdate( + measuredProps, + 0, + 50, + new ListMetricsAggregator(), + createViewToken, + onViewableItemsChanged, + undefined, + true, + ); + + expect(onViewableItemsChanged).toHaveBeenLastCalledWith({ + changed: [{isViewable: false, key: 'a'}], + viewabilityConfig: config, + viewableItems: [], + }); + }); + + it('publishes removals after minimum view time when suppressed', function () { + const config = { + minimumViewTime: 350, + viewAreaCoveragePercentThreshold: 0, + }; + const helper = new ViewabilityHelper(config); + rowFrames = {a: {y: 0, height: 50}}; + data = [{key: 'a'}]; + const measuredProps: CellMetricProps = { + ...props, + data, + getItem: (items, index) => items[index], + }; + const onViewableItemsChanged = jest.fn(); + helper.onUpdate( + measuredProps, + 0, + 50, + createMeasuredListMetrics(), + createViewToken, + onViewableItemsChanged, + ); + jest.runAllTimers(); + onViewableItemsChanged.mockClear(); + + helper.onUpdate( + measuredProps, + 0, + 50, + new ListMetricsAggregator(), + createViewToken, + onViewableItemsChanged, + undefined, + true, + ); + jest.runAllTimers(); + + expect(onViewableItemsChanged).toHaveBeenCalledWith({ + changed: [{isViewable: false, key: 'a'}], + viewabilityConfig: config, + viewableItems: [], + }); + }); + + it('does not postpone a pending suppression update', function () { + const config = { + minimumViewTime: 350, + viewAreaCoveragePercentThreshold: 0, + }; + const helper = new ViewabilityHelper(config); + rowFrames = {a: {y: 0, height: 50}}; + data = [{key: 'a'}]; + const measuredProps: CellMetricProps = { + ...props, + data, + getItem: (items, index) => items[index], + }; + const onViewableItemsChanged = jest.fn(); + helper.onUpdate( + measuredProps, + 0, + 50, + createMeasuredListMetrics(), + createViewToken, + onViewableItemsChanged, + ); + jest.runAllTimers(); + onViewableItemsChanged.mockClear(); + + helper.onUpdate( + measuredProps, + 0, + 50, + new ListMetricsAggregator(), + createViewToken, + onViewableItemsChanged, + undefined, + true, + ); + jest.advanceTimersByTime(200); + helper.onUpdate( + measuredProps, + 0, + 50, + new ListMetricsAggregator(), + createViewToken, + onViewableItemsChanged, + undefined, + true, + ); + jest.advanceTimersByTime(150); + + expect(onViewableItemsChanged).toHaveBeenCalledTimes(1); + expect(onViewableItemsChanged).toHaveBeenCalledWith({ + changed: [{isViewable: false, key: 'a'}], + viewabilityConfig: config, + viewableItems: [], + }); + }); + it('returns 1 visible row as viewable then scrolls away', function () { const helper = new ViewabilityHelper(); rowFrames = {