From be0a23dfaf48b0fa30645d1e6265a05ee9f25f38 Mon Sep 17 00:00:00 2001 From: Amro Altahtamouni Date: Thu, 1 Oct 2026 14:22:35 -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 new behavior is gated behind the `fixCrossOrientationNestedListViewability` JS feature flag, which defaults to off. While it is off, parents do not expose the cell-visibility query, so cross-orientation children are never registered or suppressed and nested lists behave as before. Independently of the flag, `ViewabilityHelper` now invalidates a pending `minimumViewTime` callback when an update returns early. 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 Reviewed By: Abbondanzo Differential Revision: D117727511 --- packages/react-native/ReactNativeApi.d.ts | 5 +- .../ReactNativeFeatureFlags.config.js | 11 + .../featureflags/ReactNativeFeatureFlags.js | 8 +- .../Lists/ViewabilityHelper.js | 51 +- .../Lists/VirtualizedList.js | 263 +++++- .../Lists/VirtualizedListContext.js | 10 +- .../Lists/__tests__/ViewabilityHelper-test.js | 330 ++++++++ ...edList-nested-viewability-disabled-test.js | 127 +++ ...VirtualizedList-nested-viewability-test.js | 763 ++++++++++++++++++ 9 files changed, 1530 insertions(+), 38 deletions(-) create mode 100644 packages/virtualized-lists/Lists/__tests__/VirtualizedList-nested-viewability-disabled-test.js create mode 100644 packages/virtualized-lists/Lists/__tests__/VirtualizedList-nested-viewability-test.js diff --git a/packages/react-native/ReactNativeApi.d.ts b/packages/react-native/ReactNativeApi.d.ts index 2891dd53661e..5fca08b360d0 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<<3ce9599aceb36dd1f09ded3db3332daa>> + * @generated SignedSource<> * * This file was generated by scripts/js-api/build-types/index.js. */ @@ -1771,6 +1771,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: () => { @@ -1785,6 +1786,7 @@ declare type Context = { } readonly registerAsNestedChild: ($$PARAM_0$$: { cellKey: string + horizontal?: boolean ref: VirtualizedList_default }) => void readonly unregisterAsNestedChild: ($$PARAM_0$$: { @@ -5503,6 +5505,7 @@ declare class ViewabilityHelper_default { first: number last: number }, + suppressViewableItems?: boolean, ): void recordInteraction(): void resetViewableIndices(): void diff --git a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js index 9d776f20b10e..bf942d588471 100644 --- a/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js +++ b/packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js @@ -1091,6 +1091,17 @@ const definitions: FeatureFlagDefinitions = { }, ossReleaseStage: 'none', }, + fixCrossOrientationNestedListViewability: { + defaultValue: false, + metadata: { + dateAdded: '2026-09-29', + description: + 'When enabled, a VirtualizedList nested inside a list of the opposite orientation does not report viewable items while its containing cell is outside the parent viewport.', + expectedReleaseValue: true, + purpose: 'experimentation', + }, + ossReleaseStage: 'none', + }, fixVirtualizeListCollapseWindowSize: { defaultValue: false, metadata: { diff --git a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js index b06551330c29..d71555b7c1c7 100644 --- a/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js +++ b/packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js @@ -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<<6e73a99aafb36218e1d679637bb80c82>> + * @generated SignedSource<<0c6ae0cc911ae8ee487183dca0b0015c>> * @flow strict * @noformat */ @@ -37,6 +37,7 @@ export type ReactNativeFeatureFlagsJsOnly = Readonly<{ enableImperativeEvents_DEPRECATED: Getter, enableNativeEventTargetEventDispatching: Getter, externalElementInspectionEnabled: Getter, + fixCrossOrientationNestedListViewability: Getter, fixVirtualizeListCollapseWindowSize: Getter, isLayoutAnimationEnabled: Getter, shouldUseAnimatedObjectForTransform: Getter, @@ -185,6 +186,11 @@ export const enableNativeEventTargetEventDispatching: Getter = createJa */ export const externalElementInspectionEnabled: Getter = createJavaScriptFlagGetter('externalElementInspectionEnabled', true); +/** + * When enabled, a VirtualizedList nested inside a list of the opposite orientation does not report viewable items while its containing cell is outside the parent viewport. + */ +export const fixCrossOrientationNestedListViewability: Getter = createJavaScriptFlagGetter('fixCrossOrientationNestedListViewability', false); + /** * Fixing an edge case where the current window size is not properly calculated with fast scrolling. Window size collapsed to 1 element even if windowSize more than the current amount of elements */ diff --git a/packages/virtualized-lists/Lists/ViewabilityHelper.js b/packages/virtualized-lists/Lists/ViewabilityHelper.js index 59c965430247..5fc5addc11b8 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: ReturnType = 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 601419b1e804..9d4616f55a96 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}, @@ -357,20 +376,163 @@ 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(); + } + const suppressViewableItems = this._shouldSuppressViewableItems(); + if (suppressViewableItems !== childList.ref._isAncestorSuppressed) { + childList.ref._onParentViewportChanged(suppressViewableItems); + } + 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) { @@ -686,18 +848,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); // $FlowFixMe[incompatible-type] clearTimeout(this._updateCellsToRenderTimeoutID); this._viewabilityTuples.forEach(tuple => { @@ -875,6 +1030,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'; @@ -1141,6 +1345,10 @@ class VirtualizedList extends StateSafePureComponent< getOutermostParentListRef: this._getOutermostParentListRef, registerAsNestedChild: this._registerAsNestedChild, unregisterAsNestedChild: this._unregisterAsNestedChild, + getCellVisibilityByKey: + ReactNativeFeatureFlags.fixCrossOrientationNestedListViewability() + ? this._getCellVisibilityByKey + : undefined, }}> {cloneElement( ( @@ -1195,6 +1403,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 @@ -1238,9 +1447,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; @@ -1342,6 +1557,7 @@ class VirtualizedList extends StateSafePureComponent< this._triggerRemeasureForChildListsInCell(cellKey); this._computeBlankness(); this._updateViewableItems(this.props, this.state.cellsAroundViewport); + this._notifyCrossOrientationChildren(); }; _onCellFocusCapture = (cellKey: string) => { @@ -1402,6 +1618,7 @@ class VirtualizedList extends StateSafePureComponent< this._nestedChildLists.forEach(childList => { childList.measureLayoutRelativeToContainingList(); }); + this._notifyCrossOrientationChildren(); } }, error => { @@ -1431,6 +1648,7 @@ class VirtualizedList extends StateSafePureComponent< this._scrollMetrics.visibleLength = this._selectLength( e.nativeEvent.layout, ); + this._notifyCrossOrientationChildren(); } this.props.onLayout && this.props.onLayout(e); this._scheduleCellsToRenderUpdate(); @@ -1787,6 +2005,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; } @@ -1940,6 +2159,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( @@ -2049,16 +2269,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, @@ -2068,6 +2294,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 = { diff --git a/packages/virtualized-lists/Lists/__tests__/VirtualizedList-nested-viewability-disabled-test.js b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-nested-viewability-disabled-test.js new file mode 100644 index 000000000000..816cdab72668 --- /dev/null +++ b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-nested-viewability-disabled-test.js @@ -0,0 +1,127 @@ +/** + * 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 strict-local + * @format + */ + +import type {CellRendererProps} from '../VirtualizedListProps'; +import type {LayoutRectangle} from 'react-native'; +import type {ReactTestRenderer} from 'react-test-renderer'; + +import VirtualizedList from '../VirtualizedList'; +import * as React from 'react'; +import {View} from 'react-native'; +import {act, create} from 'react-test-renderer'; + +jest.useFakeTimers(); + +type Item = {key: string}; + +let renderer: ?ReactTestRenderer = null; + +function getRenderer(): ReactTestRenderer { + if (renderer == null) { + throw new Error('Expected a rendered tree.'); + } + return renderer; +} + +async function renderAsync(element: React.MixedElement): Promise { + await act(async () => { + renderer = create(element); + }); +} + +async function unmountAsync(): Promise { + await act(async () => { + getRenderer().unmount(); + }); + renderer = null; +} + +component NestedListTestCell(...props: CellRendererProps) { + const {cellKey, children, onLayout, style} = props; + return ( + + {children} + + ); +} + +function fireLayout(testID: string, layout: LayoutRectangle): void { + const [target] = getRenderer().root.findAll( + node => + node.props != null && + node.props.testID === testID && + typeof node.props.onLayout === 'function', + ); + if (target == null) { + throw new Error(`Expected ${testID} to have an onLayout handler.`); + } + act(() => { + target.props.onLayout({nativeEvent: {layout}}); + }); +} + +describe('VirtualizedList nested viewability with the flag disabled', () => { + test('cross-orientation children keep reporting independently of the parent viewport', async () => { + const outerData: Array = [{key: 'outer0'}, {key: 'outer1'}]; + const innerData: Array = [{key: 'inner0'}, {key: 'inner1'}]; + const offscreenOnViewable = jest.fn(); + + await renderAsync( + items[index]} + getItemCount={items => items.length} + initialNumToRender={2} + renderItem={({item}) => ( + items[index]} + getItemCount={items => items.length} + getItemLayout={(_, index) => ({ + index, + length: 100, + offset: index * 100, + })} + horizontal={true} + onViewableItemsChanged={ + item.key === 'outer1' ? offscreenOnViewable : undefined + } + renderItem={({item: innerItem}) => ( + + )} + testID={`inner-${item.key}`} + viewabilityConfig={{viewAreaCoveragePercentThreshold: 0}} + /> + )} + testID="outer-list" + />, + ); + + await act(async () => { + fireLayout('outer-list', {height: 100, width: 300, x: 0, y: 0}); + fireLayout('outer-cell-outer0', {height: 100, width: 300, x: 0, y: 0}); + fireLayout('outer-cell-outer1', {height: 100, width: 300, x: 0, y: 100}); + fireLayout('inner-outer1', {height: 100, width: 200, x: 0, y: 0}); + jest.runAllTimers(); + }); + + expect(offscreenOnViewable).toHaveBeenLastCalledWith( + expect.objectContaining({ + viewableItems: [ + expect.objectContaining({isViewable: true, key: 'inner0'}), + expect.objectContaining({isViewable: true, key: 'inner1'}), + ], + }), + ); + + await unmountAsync(); + }); +}); diff --git a/packages/virtualized-lists/Lists/__tests__/VirtualizedList-nested-viewability-test.js b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-nested-viewability-test.js new file mode 100644 index 000000000000..0ab93cf3b8e6 --- /dev/null +++ b/packages/virtualized-lists/Lists/__tests__/VirtualizedList-nested-viewability-test.js @@ -0,0 +1,763 @@ +/** + * 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 strict-local + * @format + */ + +import type {CellRendererProps} from '../VirtualizedListProps'; +import type {LayoutRectangle} from 'react-native'; +import type {ReactTestRenderer} from 'react-test-renderer'; + +import VirtualizedList from '../VirtualizedList'; +import {VirtualizedListContext} from '../VirtualizedListContext'; +import * as React from 'react'; +import {View} from 'react-native'; +import {ReactNativeFeatureFlags} from 'react-native/react-private-interface'; +import {act, create} from 'react-test-renderer'; + +ReactNativeFeatureFlags.override({ + fixCrossOrientationNestedListViewability: () => true, +}); + +jest.useFakeTimers(); + +type Item = {key: string}; + +let renderer: ?ReactTestRenderer = null; + +function getRenderer(): ReactTestRenderer { + if (renderer == null) { + throw new Error('Expected a rendered tree.'); + } + return renderer; +} + +async function renderAsync(element: React.MixedElement): Promise { + await act(async () => { + renderer = create(element); + }); +} + +async function rerenderAsync(element: React.MixedElement): Promise { + await act(async () => { + getRenderer().update(element); + }); +} + +async function unmountAsync(): Promise { + await act(async () => { + getRenderer().unmount(); + }); + renderer = null; +} + +function fireEvent( + testID: string, + eventName: string, + ...args: ReadonlyArray +): void { + const handlerName = `on${eventName[0].toUpperCase()}${eventName.slice(1)}`; + const [target] = getRenderer().root.findAll( + node => + node.props != null && + node.props.testID === testID && + typeof node.props[handlerName] === 'function', + ); + if (target == null) { + throw new Error(`Expected ${testID} to have an ${handlerName} handler.`); + } + act(() => { + target.props[handlerName](...args); + }); +} + +component NestedListTestCell(...props: CellRendererProps) { + const {cellKey, children, onLayout, style} = props; + return ( + + {children} + + ); +} + +function fireListLayout( + testID: string, + viewport: LayoutRectangle, + content: {height: number, width: number}, +): void { + fireEvent(testID, 'layout', {nativeEvent: {layout: viewport}}); + fireEvent(testID, 'contentSizeChange', content.width, content.height); +} + +function fireListScroll( + testID: string, + contentOffset: {x: number, y: number}, + viewport: LayoutRectangle, + content: {height: number, width: number}, +): void { + fireEvent(testID, 'scroll', { + nativeEvent: { + contentOffset, + contentSize: content, + layoutMeasurement: viewport, + zoomScale: 1, + }, + }); +} + +describe('VirtualizedList nested viewability', () => { + test('cross-orientation children follow parent row visibility transitions', async () => { + const outerData: Array = [{key: 'outer0'}, {key: 'outer1'}]; + const innerData: Array = [{key: 'inner0'}, {key: 'inner1'}]; + const inner0OnViewable = jest.fn(); + const inner1OnViewable = jest.fn(); + const inner0Ref = React.createRef(); + const inner1Ref = React.createRef(); + + await renderAsync( + items[index]} + getItemCount={items => items.length} + initialNumToRender={2} + renderItem={({item}) => ( + items[index]} + getItemCount={items => items.length} + getItemLayout={(_, index) => ({ + index, + length: 100, + offset: index * 100, + })} + horizontal={true} + onViewableItemsChanged={ + item.key === 'outer0' ? inner0OnViewable : inner1OnViewable + } + renderItem={({item: innerItem}) => ( + + )} + testID={`inner-${item.key}`} + viewabilityConfig={{viewAreaCoveragePercentThreshold: 0}} + /> + )} + testID="outer-list" + />, + ); + + await act(async () => { + fireListLayout( + 'outer-list', + {height: 100, width: 300, x: 0, y: 0}, + {height: 200, width: 300}, + ); + fireListLayout( + 'inner-outer0', + {height: 100, width: 200, x: 0, y: 0}, + {height: 100, width: 200}, + ); + fireListLayout( + 'inner-outer1', + {height: 100, width: 200, x: 0, y: 0}, + {height: 100, width: 200}, + ); + jest.runAllTimers(); + }); + + expect(inner0OnViewable).not.toHaveBeenCalled(); + expect(inner1OnViewable).not.toHaveBeenCalled(); + + await act(async () => { + fireEvent('outer-cell-outer0', 'layout', { + nativeEvent: { + layout: {height: 100, width: 300, x: 0, y: 0}, + }, + }); + fireEvent('outer-cell-outer1', 'layout', { + nativeEvent: { + layout: {height: 100, width: 300, x: 0, y: 100}, + }, + }); + jest.runAllTimers(); + }); + + expect(inner0OnViewable).toHaveBeenLastCalledWith( + expect.objectContaining({ + viewableItems: [ + expect.objectContaining({isViewable: true, key: 'inner0'}), + expect.objectContaining({isViewable: true, key: 'inner1'}), + ], + }), + ); + expect(inner1OnViewable).not.toHaveBeenCalled(); + + await act(async () => { + fireListScroll( + 'outer-list', + {x: 0, y: 100}, + {height: 100, width: 300, x: 0, y: 0}, + {height: 200, width: 300}, + ); + jest.runAllTimers(); + }); + + expect(inner0OnViewable).toHaveBeenLastCalledWith( + expect.objectContaining({ + changed: expect.arrayContaining([ + expect.objectContaining({isViewable: false, key: 'inner0'}), + expect.objectContaining({isViewable: false, key: 'inner1'}), + ]), + viewableItems: [], + }), + ); + expect(inner1OnViewable).toHaveBeenLastCalledWith( + expect.objectContaining({ + viewableItems: [ + expect.objectContaining({isViewable: true, key: 'inner0'}), + expect.objectContaining({isViewable: true, key: 'inner1'}), + ], + }), + ); + + const inner0 = inner0Ref.current; + const inner1 = inner1Ref.current; + if (inner0 == null || inner1 == null) { + throw new Error('Expected both nested lists to be mounted.'); + } + const inner0ViewportUpdate = jest.spyOn(inner0, '_onParentViewportChanged'); + const inner1ViewportUpdate = jest.spyOn(inner1, '_onParentViewportChanged'); + await act(async () => { + fireListScroll( + 'outer-list', + {x: 0, y: 110}, + {height: 100, width: 300, x: 0, y: 0}, + {height: 200, width: 300}, + ); + fireListScroll( + 'outer-list', + {x: 0, y: 120}, + {height: 100, width: 300, x: 0, y: 0}, + {height: 200, width: 300}, + ); + jest.runAllTimers(); + }); + + expect(inner0ViewportUpdate).not.toHaveBeenCalled(); + expect(inner1ViewportUpdate).not.toHaveBeenCalled(); + + const childError = new Error('Expected child callback failure.'); + inner0ViewportUpdate.mockImplementationOnce(() => { + throw childError; + }); + expect(() => + fireListScroll( + 'outer-list', + {x: 0, y: 0}, + {height: 100, width: 300, x: 0, y: 0}, + {height: 200, width: 300}, + ), + ).toThrow(childError); + expect(inner0ViewportUpdate).toHaveBeenCalledWith(false); + expect(inner1ViewportUpdate).toHaveBeenCalledWith(true); + + fireListScroll( + 'outer-list', + {x: 0, y: 0}, + {height: 100, width: 300, x: 0, y: 0}, + {height: 200, width: 300}, + ); + expect(inner0ViewportUpdate).toHaveBeenCalledTimes(2); + expect(inner1ViewportUpdate).toHaveBeenCalledWith(true); + fireListScroll( + 'outer-list', + {x: 0, y: 100}, + {height: 100, width: 300, x: 0, y: 0}, + {height: 200, width: 300}, + ); + expect(inner0ViewportUpdate).toHaveBeenCalledTimes(3); + expect(inner0ViewportUpdate).toHaveBeenLastCalledWith(true); + expect(inner1ViewportUpdate).toHaveBeenCalledTimes(2); + expect(inner1ViewportUpdate).toHaveBeenLastCalledWith(false); + + inner0ViewportUpdate.mockRestore(); + inner1ViewportUpdate.mockRestore(); + await unmountAsync(); + }); + + test('uses current scroll metrics when clearing a pending scroll update', async () => { + const innerOnViewable = jest.fn(); + await renderAsync( + items[index]} + getItemCount={items => items.length} + getItemLayout={(_, index) => ({ + index, + length: 100, + offset: index * 100, + })} + initialNumToRender={2} + initialScrollIndex={1} + renderItem={({item}) => ( + items[index]} + getItemCount={items => items.length} + getItemLayout={(_, index) => ({index, length: 100, offset: 0})} + horizontal={true} + onViewableItemsChanged={ + item.key === 'outer1' ? innerOnViewable : undefined + } + renderItem={({item: innerItem}) => ( + + )} + testID={`pending-inner-${item.key}`} + viewabilityConfig={{viewAreaCoveragePercentThreshold: 0}} + /> + )} + testID="pending-outer-list" + />, + ); + + fireListLayout( + 'pending-outer-list', + {height: 100, width: 300, x: 0, y: 0}, + {height: 200, width: 300}, + ); + fireListLayout( + 'pending-inner-outer1', + {height: 100, width: 100, x: 0, y: 0}, + {height: 100, width: 100}, + ); + expect(innerOnViewable).not.toHaveBeenCalled(); + + fireListScroll( + 'pending-outer-list', + {x: 0, y: 100}, + {height: 100, width: 300, x: 0, y: 0}, + {height: 200, width: 300}, + ); + + expect(innerOnViewable).toHaveBeenLastCalledWith( + expect.objectContaining({ + viewableItems: [ + expect.objectContaining({isViewable: true, key: 'inner'}), + ], + }), + ); + await unmountAsync(); + }); + + test('suppresses children while the parent has no cross-axis size', async () => { + const innerOnViewable = jest.fn(); + await renderAsync( + items[index]} + getItemCount={items => items.length} + getItemLayout={(_, index) => ({index, length: 100, offset: 0})} + renderItem={() => ( + items[index]} + getItemCount={items => items.length} + getItemLayout={(_, index) => ({index, length: 100, offset: 0})} + horizontal={true} + onViewableItemsChanged={innerOnViewable} + renderItem={({item}) => } + testID="zero-width-inner-list" + viewabilityConfig={{viewAreaCoveragePercentThreshold: 0}} + /> + )} + testID="zero-width-outer-list" + />, + ); + + fireListLayout( + 'zero-width-outer-list', + {height: 100, width: 0, x: 0, y: 0}, + {height: 100, width: 0}, + ); + fireListLayout( + 'zero-width-inner-list', + {height: 100, width: 100, x: 0, y: 0}, + {height: 100, width: 100}, + ); + + expect(innerOnViewable).not.toHaveBeenCalled(); + await unmountAsync(); + }); + + test('does not suppress cross-orientation lists in structural cells', async () => { + const innerOnViewable = jest.fn(); + await renderAsync( + items[index]} + getItemCount={items => items.length} + getItemLayout={(_, index) => ({index, length: 100, offset: 0})} + horizontal={true} + onViewableItemsChanged={innerOnViewable} + renderItem={({item}) => } + testID="header-inner-list" + viewabilityConfig={{viewAreaCoveragePercentThreshold: 0}} + /> + } + data={[]} + getItem={(items, index) => items[index]} + getItemCount={items => items.length} + renderItem={() => null} + testID="header-outer-list" + />, + ); + + fireListLayout( + 'header-inner-list', + {height: 100, width: 100, x: 0, y: 0}, + {height: 100, width: 100}, + ); + await act(async () => { + jest.runAllTimers(); + }); + expect(innerOnViewable).toHaveBeenLastCalledWith( + expect.objectContaining({ + viewableItems: [ + expect.objectContaining({isViewable: true, key: 'inner'}), + ], + }), + ); + + fireListLayout( + 'header-outer-list', + {height: 100, width: 300, x: 0, y: 0}, + {height: 100, width: 300}, + ); + + expect(innerOnViewable).toHaveBeenCalledTimes(1); + await unmountAsync(); + }); + + test('propagates ancestor suppression to same-orientation children', async () => { + const parentRef = React.createRef(); + const childRef = React.createRef(); + await renderAsync( + items[index]} + getItemCount={items => items.length} + horizontal={true} + renderItem={() => ( + items[index]} + getItemCount={items => items.length} + horizontal={true} + renderItem={({item}) => } + /> + )} + />, + ); + const parent = parentRef.current; + const child = childRef.current; + if (parent == null || child == null) { + throw new Error('Expected both nested lists to be mounted.'); + } + const parentViewportUpdate = jest.spyOn(parent, '_onParentViewportChanged'); + const childViewportUpdate = jest.spyOn(child, '_onParentViewportChanged'); + const childShouldSuppress = jest.spyOn( + child, + '_shouldSuppressViewableItems', + ); + + await act(async () => { + parentViewportUpdate(true); + }); + + expect(childViewportUpdate).toHaveBeenCalledWith(true); + expect(childShouldSuppress.call(child)).toBe(true); + + childViewportUpdate.mockClear(); + const unregister = jest.spyOn(parent, '_unregisterAsNestedChild'); + const register = jest.spyOn(parent, '_registerAsNestedChild'); + unregister({ref: child}); + register({cellKey: 'parent', horizontal: true, ref: child}); + expect(childShouldSuppress.call(child)).toBe(true); + + await act(async () => { + parentViewportUpdate(false); + }); + + expect(childShouldSuppress.call(child)).toBe(false); + unregister.mockRestore(); + register.mockRestore(); + parentViewportUpdate.mockRestore(); + childViewportUpdate.mockRestore(); + childShouldSuppress.mockRestore(); + await unmountAsync(); + }); + + test('immediately suppresses a child registered in an offscreen row', async () => { + const parentRef = React.createRef(); + const childRef = React.createRef(); + await renderAsync( + <> + items[index]} + getItemCount={items => items.length} + getItemLayout={(_, index) => ({ + index, + length: 100, + offset: index * 100, + })} + initialNumToRender={2} + renderItem={({item}) => } + testID="registration-parent-list" + /> + items[index]} + getItemCount={items => items.length} + horizontal={true} + renderItem={({item}) => } + /> + , + ); + + fireListLayout( + 'registration-parent-list', + {height: 100, width: 300, x: 0, y: 0}, + {height: 200, width: 300}, + ); + fireListScroll( + 'registration-parent-list', + {x: 0, y: 100}, + {height: 100, width: 300, x: 0, y: 0}, + {height: 200, width: 300}, + ); + + const parent = parentRef.current; + const child = childRef.current; + if (parent == null || child == null) { + throw new Error('Expected both lists to be mounted.'); + } + const register = jest.spyOn(parent, '_registerAsNestedChild'); + const notify = jest.spyOn(parent, '_notifyCrossOrientationChildren'); + const childViewportUpdate = jest.spyOn(child, '_onParentViewportChanged'); + childViewportUpdate.mockImplementationOnce(() => { + notify.call(parent); + }); + + register({cellKey: 'outer0', horizontal: true, ref: child}); + + expect(childViewportUpdate).toHaveBeenCalledWith(true); + expect(childViewportUpdate).toHaveBeenCalledTimes(1); + register.mockRestore(); + notify.mockRestore(); + childViewportUpdate.mockRestore(); + await unmountAsync(); + }); + + test('ancestor suppression propagates through nested cross-orientation lists', async () => { + const grandchildOnViewable = jest.fn(); + await renderAsync( + items[index]} + getItemCount={items => items.length} + getItemLayout={(_, index) => ({ + index, + length: 100, + offset: index * 100, + })} + initialNumToRender={2} + renderItem={({item}) => + item.key === 'outer0' ? ( + items[index]} + getItemCount={items => items.length} + getItemLayout={(_, index) => ({ + index, + length: 200, + offset: index * 200, + })} + horizontal={true} + renderItem={() => ( + items[index]} + getItemCount={items => items.length} + getItemLayout={(_, index) => ({ + index, + length: 100, + offset: index * 100, + })} + onViewableItemsChanged={grandchildOnViewable} + renderItem={({item: grandchild}) => ( + + )} + testID="grandchild-list" + viewabilityConfig={{viewAreaCoveragePercentThreshold: 0}} + /> + )} + testID="middle-list" + /> + ) : ( + + ) + } + testID="deep-outer-list" + />, + ); + + await act(async () => { + fireListLayout( + 'deep-outer-list', + {height: 100, width: 300, x: 0, y: 0}, + {height: 200, width: 300}, + ); + fireListLayout( + 'middle-list', + {height: 100, width: 200, x: 0, y: 0}, + {height: 100, width: 200}, + ); + fireListLayout( + 'grandchild-list', + {height: 100, width: 200, x: 0, y: 0}, + {height: 100, width: 200}, + ); + jest.runAllTimers(); + }); + + expect(grandchildOnViewable).toHaveBeenLastCalledWith( + expect.objectContaining({ + viewableItems: [ + expect.objectContaining({isViewable: true, key: 'grandchild'}), + ], + }), + ); + + await act(async () => { + fireListScroll( + 'deep-outer-list', + {x: 0, y: 100}, + {height: 100, width: 300, x: 0, y: 0}, + {height: 200, width: 300}, + ); + jest.runAllTimers(); + }); + + expect(grandchildOnViewable).toHaveBeenLastCalledWith( + expect.objectContaining({ + changed: [ + expect.objectContaining({isViewable: false, key: 'grandchild'}), + ], + viewableItems: [], + }), + ); + await unmountAsync(); + }); + + test('orientation updates reconcile registration and unmount cleanup', async () => { + const childRef = React.createRef(); + const registerAsNestedChild = jest.fn(); + const unregisterAsNestedChild = jest.fn(); + const contextValue = { + cellKey: 'parent-cell', + getCellVisibilityByKey: () => true, + getOutermostParentListRef: () => { + const child = childRef.current; + if (child == null) { + throw new Error('Expected the child list to be mounted.'); + } + return child; + }, + getScrollMetrics: () => ({ + contentLength: 100, + dOffset: 0, + dt: 1, + offset: 0, + timestamp: 0, + velocity: 0, + visibleLength: 100, + zoomScale: 1, + }), + horizontal: false, + registerAsNestedChild, + unregisterAsNestedChild, + }; + const renderChild = ( + context: ?typeof contextValue, + horizontal: boolean, + ): React.MixedElement => ( + + items[index]} + getItemCount={items => items.length} + horizontal={horizontal} + renderItem={({item}) => } + /> + + ); + + await renderAsync(renderChild(contextValue, true)); + const childInstance = childRef.current; + if (childInstance == null) { + throw new Error('Expected the child list to be mounted.'); + } + + expect(registerAsNestedChild).toHaveBeenCalledWith({ + cellKey: 'parent-cell', + horizontal: true, + ref: childInstance, + }); + await rerenderAsync(renderChild(contextValue, false)); + expect(unregisterAsNestedChild).toHaveBeenCalledWith({ + ref: childInstance, + }); + expect(registerAsNestedChild).toHaveBeenCalledWith({ + cellKey: 'parent-cell', + horizontal: false, + ref: childInstance, + }); + + const childViewportUpdate = jest.spyOn( + childInstance, + '_onParentViewportChanged', + ); + const childShouldSuppress = jest.spyOn( + childInstance, + '_shouldSuppressViewableItems', + ); + childViewportUpdate(true); + expect(childShouldSuppress.call(childInstance)).toBe(true); + + await rerenderAsync(renderChild(null, false)); + + expect(childViewportUpdate).toHaveBeenLastCalledWith(false); + expect(childShouldSuppress.call(childInstance)).toBe(false); + childViewportUpdate.mockRestore(); + childShouldSuppress.mockRestore(); + + await unmountAsync(); + + expect(unregisterAsNestedChild).toHaveBeenCalledWith({ + ref: childInstance, + }); + }); +});