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, + }); + }); +});