Skip to content

Commit 3954161

Browse files
amroaltahfacebook-github-bot
authored andcommitted
Fix viewability reporting for cross-orientation nested lists (#58412)
Summary: 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 #57797 Fixes #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
1 parent 6421738 commit 3954161

9 files changed

Lines changed: 1530 additions & 38 deletions

File tree

‎packages/react-native/ReactNativeApi.d.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<3ce9599aceb36dd1f09ded3db3332daa>>
7+
* @generated SignedSource<<adc11a9d577530725c3bf23ed3806582>>
88
*
99
* This file was generated by scripts/js-api/build-types/index.js.
1010
*/
@@ -1771,6 +1771,7 @@ declare function configureNext(
17711771
declare type ContentAvailable = 1 | null | void
17721772
declare type Context = {
17731773
readonly cellKey: string | undefined
1774+
readonly getCellVisibilityByKey?: (cellKey: string) => boolean | undefined
17741775
readonly horizontal: boolean | undefined
17751776
readonly getOutermostParentListRef: () => VirtualizedList_default
17761777
readonly getScrollMetrics: () => {
@@ -1785,6 +1786,7 @@ declare type Context = {
17851786
}
17861787
readonly registerAsNestedChild: ($$PARAM_0$$: {
17871788
cellKey: string
1789+
horizontal?: boolean
17881790
ref: VirtualizedList_default
17891791
}) => void
17901792
readonly unregisterAsNestedChild: ($$PARAM_0$$: {
@@ -5503,6 +5505,7 @@ declare class ViewabilityHelper_default {
55035505
first: number
55045506
last: number
55055507
},
5508+
suppressViewableItems?: boolean,
55065509
): void
55075510
recordInteraction(): void
55085511
resetViewableIndices(): void

‎packages/react-native/scripts/featureflags/ReactNativeFeatureFlags.config.js‎

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1091,6 +1091,17 @@ const definitions: FeatureFlagDefinitions = {
10911091
},
10921092
ossReleaseStage: 'none',
10931093
},
1094+
fixCrossOrientationNestedListViewability: {
1095+
defaultValue: false,
1096+
metadata: {
1097+
dateAdded: '2026-09-29',
1098+
description:
1099+
'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.',
1100+
expectedReleaseValue: true,
1101+
purpose: 'experimentation',
1102+
},
1103+
ossReleaseStage: 'none',
1104+
},
10941105
fixVirtualizeListCollapseWindowSize: {
10951106
defaultValue: false,
10961107
metadata: {

‎packages/react-native/src/private/featureflags/ReactNativeFeatureFlags.js‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
* This source code is licensed under the MIT license found in the
55
* LICENSE file in the root directory of this source tree.
66
*
7-
* @generated SignedSource<<6e73a99aafb36218e1d679637bb80c82>>
7+
* @generated SignedSource<<0c6ae0cc911ae8ee487183dca0b0015c>>
88
* @flow strict
99
* @noformat
1010
*/
@@ -37,6 +37,7 @@ export type ReactNativeFeatureFlagsJsOnly = Readonly<{
3737
enableImperativeEvents_DEPRECATED: Getter<boolean>,
3838
enableNativeEventTargetEventDispatching: Getter<boolean>,
3939
externalElementInspectionEnabled: Getter<boolean>,
40+
fixCrossOrientationNestedListViewability: Getter<boolean>,
4041
fixVirtualizeListCollapseWindowSize: Getter<boolean>,
4142
isLayoutAnimationEnabled: Getter<boolean>,
4243
shouldUseAnimatedObjectForTransform: Getter<boolean>,
@@ -185,6 +186,11 @@ export const enableNativeEventTargetEventDispatching: Getter<boolean> = createJa
185186
*/
186187
export const externalElementInspectionEnabled: Getter<boolean> = createJavaScriptFlagGetter('externalElementInspectionEnabled', true);
187188

189+
/**
190+
* 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.
191+
*/
192+
export const fixCrossOrientationNestedListViewability: Getter<boolean> = createJavaScriptFlagGetter('fixCrossOrientationNestedListViewability', false);
193+
188194
/**
189195
* 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
190196
*/

‎packages/virtualized-lists/Lists/ViewabilityHelper.js‎

Lines changed: 34 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,9 @@ export type ViewabilityConfig = Readonly<{
8282
class ViewabilityHelper {
8383
_config: ViewabilityConfig;
8484
_hasInteracted: boolean = false;
85-
_timers: Set<number> = new Set();
85+
_pendingSuppressedUpdate: boolean = false;
86+
_timers: Set<ReturnType<typeof setTimeout>> = new Set();
87+
_updateGeneration: number = 0;
8688
_viewableIndices: Array<number> = [];
8789
_viewableItems: Map<string, ViewToken> = new Map();
8890

@@ -96,9 +98,6 @@ class ViewabilityHelper {
9698
* Cleanup, e.g. on unmount. Clears any pending timers.
9799
*/
98100
dispose() {
99-
/* $FlowFixMe[incompatible-type] (>=0.63.0 site=react_native_fb) This
100-
* comment suppresses an error found when Flow v0.63 was deployed. To see
101-
* the error delete this comment and run Flow. */
102101
this._timers.forEach(clearTimeout);
103102
}
104103

@@ -197,17 +196,33 @@ class ViewabilityHelper {
197196
last: number,
198197
...
199198
},
199+
// Suppression bypasses the normal early returns so an empty result clears
200+
// items reported before an ancestor moved off screen.
201+
suppressViewableItems?: boolean,
200202
): void {
203+
const updateGeneration = this._updateGeneration + 1;
201204
const itemCount = props.getItemCount(props.data);
202205
if (
203-
(this._config.waitForInteraction && !this._hasInteracted) ||
204-
itemCount === 0 ||
205-
!listMetrics.getCellMetrics(0, props)
206+
suppressViewableItems !== true &&
207+
this._config.waitForInteraction &&
208+
!this._hasInteracted
206209
) {
210+
this._updateGeneration = updateGeneration;
211+
this._pendingSuppressedUpdate = false;
212+
this._viewableIndices = [];
213+
return;
214+
}
215+
if (
216+
suppressViewableItems !== true &&
217+
(itemCount === 0 || !listMetrics.getCellMetrics(0, props))
218+
) {
219+
this._updateGeneration = updateGeneration;
220+
this._pendingSuppressedUpdate = false;
221+
this._viewableIndices = [];
207222
return;
208223
}
209224
let viewableIndices: Array<number> = [];
210-
if (itemCount) {
225+
if (itemCount && suppressViewableItems !== true) {
211226
viewableIndices = this.computeViewableItems(
212227
props,
213228
scrollOffset,
@@ -218,35 +233,35 @@ class ViewabilityHelper {
218233
}
219234
if (
220235
this._viewableIndices.length === viewableIndices.length &&
221-
this._viewableIndices.every((v, ii) => v === viewableIndices[ii])
236+
this._viewableIndices.every((v, ii) => v === viewableIndices[ii]) &&
237+
(suppressViewableItems !== true ||
238+
this._viewableItems.size === 0 ||
239+
this._pendingSuppressedUpdate)
222240
) {
223241
// We might get a lot of scroll events where visibility doesn't change and we don't want to do
224242
// extra work in those cases.
225243
return;
226244
}
227245
this._viewableIndices = viewableIndices;
246+
this._updateGeneration = updateGeneration;
228247
if (this._config.minimumViewTime) {
248+
this._pendingSuppressedUpdate = suppressViewableItems === true;
229249
const handle: ReturnType<typeof setTimeout> = setTimeout(() => {
230-
/* $FlowFixMe[incompatible-type] (>=0.63.0 site=react_native_fb) This
231-
* comment suppresses an error found when Flow v0.63 was deployed. To
232-
* see the error delete this comment and run Flow. */
233250
this._timers.delete(handle);
234-
// `onUpdate` replaces the array whenever the visible set changes.
235-
if (this._viewableIndices !== viewableIndices) {
251+
if (this._updateGeneration !== updateGeneration) {
236252
return;
237253
}
254+
this._pendingSuppressedUpdate = false;
238255
this._onUpdateSync(
239256
props,
240257
viewableIndices,
241258
onViewableItemsChanged,
242259
createViewToken,
243260
);
244261
}, this._config.minimumViewTime);
245-
/* $FlowFixMe[incompatible-type] (>=0.63.0 site=react_native_fb) This
246-
* comment suppresses an error found when Flow v0.63 was deployed. To see
247-
* the error delete this comment and run Flow. */
248262
this._timers.add(handle);
249263
} else {
264+
this._pendingSuppressedUpdate = false;
250265
this._onUpdateSync(
251266
props,
252267
viewableIndices,
@@ -261,6 +276,8 @@ class ViewabilityHelper {
261276
*/
262277
resetViewableIndices() {
263278
this._viewableIndices = [];
279+
this._pendingSuppressedUpdate = false;
280+
this._updateGeneration++;
264281
}
265282

266283
/**

0 commit comments

Comments
 (0)