From d8c2745f1c69029d8a1120a0b65d9eb3bba67529 Mon Sep 17 00:00:00 2001 From: Amro Altahtamouni Date: Thu, 10 Sep 2026 14:28:02 -0700 Subject: [PATCH] Fix viewability reporting for cross-orientation nested lists (#58412) Summary: Pull Request resolved: https://github.com/react/react-native/pull/58412 A horizontal `VirtualizedList` nested inside a vertical list could report its items through `onViewableItemsChanged` while the parent row was off screen. The existing same-orientation nesting path translates parent scroll metrics, but cross-orientation children have independent scroll axes and therefore need the visibility of their containing row as a separate signal. Expose a semantic cell-visibility query through `VirtualizedListContext` and register cross-orientation children with the key of their containing cell. Parents evaluate those cells after scroll and layout changes, but invoke children only when the ancestor suppression state transitions. Known data rows without layout metrics fail closed, eliminating transient mount reports, while unsupported structural cells preserve the previous behavior. Explicit suppression now clears previously published viewability tokens even when current cell metrics are unavailable. It also invalidates stale `minimumViewTime` work. Suppression propagates through deeper cross-orientation nesting, and parent registration is reconciled when orientation or context changes so unmount always cleans up the exact collection that was registered. The implementation is kept in sync across the main, Windows, and macOS variants. The generated public API snapshot is updated accordingly. Fixes https://github.com/facebook/react-native/issues/57797 Fixes https://github.com/facebook/react-native/issues/57778 Changelog: [General][Fixed] - Do not report viewable items for a cross-orientation nested list while its parent row is off screen Differential Revision: D117727511 --- packages/react-native/ReactNativeApi.d.ts | 5 +- .../Lists/ViewabilityHelper.js | 51 ++- .../Lists/VirtualizedList.js | 259 +++++++++++++- .../Lists/VirtualizedListContext.js | 10 +- .../Lists/__tests__/ViewabilityHelper-test.js | 330 ++++++++++++++++++ 5 files changed, 618 insertions(+), 37 deletions(-) diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index fdbb244dd8a2..d357957982e8 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<<590f18ec6caea33fef591da5d56419f4>> + * @generated SignedSource<<0c9222928e2bb42a859e43ec4d9736c7>> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -1778,6 +1778,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: () => { @@ -1792,6 +1793,7 @@ declare type Context = { } readonly registerAsNestedChild: ($$PARAM_0$$: { cellKey: string + horizontal?: boolean ref: VirtualizedList_default }) => void readonly unregisterAsNestedChild: ($$PARAM_0$$: { @@ -5505,6 +5507,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..f2b9410b9d4e 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,33 @@ 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; + this._viewableIndices = []; + return; + } + if ( + suppressViewableItems !== true && + (itemCount === 0 || !listMetrics.getCellMetrics(0, props)) + ) { + this._updateGeneration = updateGeneration; + this._pendingSuppressedUpdate = false; + this._viewableIndices = []; return; } let viewableIndices: Array = []; - if (itemCount) { + if (itemCount && suppressViewableItems !== true) { viewableIndices = this.computeViewableItems( props, scrollOffset, @@ -218,23 +233,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 +259,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 +276,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..75579cd45abc 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,162 @@ 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(); + } + childList.ref._onParentViewportChanged( + this._shouldSuppressViewableItems(), + ); + return; + } + + let childLists = this._crossOrientationChildLists; + if (childLists == null) { + childLists = new Map(); + this._crossOrientationChildLists = childLists; } + const registration: CrossOrientationChildRegistration = { + cellKey: childList.cellKey, + lastSuppressed: null, + }; + childLists.set(childList.ref, registration); + const suppressViewableItems = + this._getCellVisibilityByKey(childList.cellKey) === false; + this._updateChildSuppression( + registration, + childList.ref, + suppressViewableItems, + ); }; _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._isAncestorSuppressed = suppressViewableItems; + 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 { + let firstError: null | {value: unknown} = null; + this._crossOrientationChildLists?.forEach((registration, child) => { + const suppressViewableItems = + ancestorSuppressed || + this._getCellVisibilityByKey( + registration.cellKey, + pendingScrollUpdateCount, + ) === false; + try { + this._updateChildSuppression( + registration, + child, + suppressViewableItems, + ); + } catch (error: unknown) { + if (firstError == null) { + firstError = {value: error}; + } + } + }); + if (firstError != null) { + throw firstError.value; + } + } + + _updateChildSuppression( + registration: CrossOrientationChildRegistration, + child: VirtualizedList, + suppressViewableItems: boolean, + ): void { + if (registration.lastSuppressed === suppressViewableItems) { + return; + } + const previousSuppressed = registration.lastSuppressed; + registration.lastSuppressed = suppressViewableItems; + try { + child._onParentViewportChanged(suppressViewableItems); + } catch (error: unknown) { + registration.lastSuppressed = previousSuppressed; + throw error; + } + } + + _shouldSuppressViewableItems(): boolean { + if (this._isAncestorSuppressed) { + return true; + } + 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 +857,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(false); clearTimeout(this._updateCellsToRenderTimeoutID); this._viewabilityTuples.forEach(tuple => { tuple.viewabilityHelper.dispose(); @@ -884,6 +1038,55 @@ 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(false); + context.registerAsNestedChild({ref: this, cellKey, horizontal}); + this._parentRegistration = { + cellKey, + horizontal, + register: context.registerAsNestedChild, + sameOrientation, + unregister: context.unregisterAsNestedChild, + }; + } + + _unregisterFromParent(resetSuppression: boolean = true): void { + const registration = this._parentRegistration; + if (registration != null) { + this._parentRegistration = null; + registration.unregister({ref: this}); + } + if (resetSuppression && this._isAncestorSuppressed) { + this._onParentViewportChanged(false); + } + } + _getSpacerKey = (isVertical: boolean): string => isVertical ? 'height' : 'width'; @@ -1146,6 +1349,7 @@ class VirtualizedList extends StateSafePureComponent< getOutermostParentListRef: this._getOutermostParentListRef, registerAsNestedChild: this._registerAsNestedChild, unregisterAsNestedChild: this._unregisterAsNestedChild, + getCellVisibilityByKey: this._getCellVisibilityByKey, }}> {cloneElement( ( @@ -1200,6 +1404,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 @@ -1243,9 +1448,15 @@ class VirtualizedList extends StateSafePureComponent< _headerLength = 0; _hiPriInProgress: boolean = false; // flag to prevent infinite hiPri cell limit update _indicesToKeys: Map = new Map(); + _isAncestorSuppressed: boolean = false; _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 +1558,7 @@ class VirtualizedList extends StateSafePureComponent< this._triggerRemeasureForChildListsInCell(cellKey); this._computeBlankness(); this._updateViewableItems(this.props, this.state.cellsAroundViewport); + this._notifyCrossOrientationChildren(); }; _onCellFocusCapture = (cellKey: string) => { @@ -1407,6 +1619,7 @@ class VirtualizedList extends StateSafePureComponent< this._nestedChildLists.forEach(childList => { childList.measureLayoutRelativeToContainingList(); }); + this._notifyCrossOrientationChildren(); } }, error => { @@ -1436,6 +1649,7 @@ class VirtualizedList extends StateSafePureComponent< this._scrollMetrics.visibleLength = this._selectLength( e.nativeEvent.layout, ); + this._notifyCrossOrientationChildren(); } this.props.onLayout && this.props.onLayout(e); this._scheduleCellsToRenderUpdate(); @@ -1788,6 +2002,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 +2157,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 +2267,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 +2292,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..fb2c672826e4 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,315 @@ 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('retries a pending visible update after metrics return', 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, + ); + helper.onUpdate( + measuredProps, + 0, + 50, + createMeasuredListMetrics(), + createViewToken, + onViewableItemsChanged, + ); + + jest.runAllTimers(); + + expect(onViewableItemsChanged).toHaveBeenCalledTimes(1); + expect(onViewableItemsChanged).toHaveBeenCalledWith({ + changed: [{isViewable: true, key: 'a'}], + viewabilityConfig: { + minimumViewTime: 350, + viewAreaCoveragePercentThreshold: 0, + }, + viewableItems: [{isViewable: true, key: 'a'}], + }); + }); + + 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 = {